Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42fc8318fa | |||
| 4c931bae1a | |||
| b305544f63 | |||
| faad70872b | |||
| fba494dada | |||
| ac284d9dc9 | |||
| d8f17ff7e7 | |||
| a25361f5fc | |||
| d54319a5bb | |||
| 05c1a4bc97 |
@@ -1,4 +1,5 @@
|
|||||||
/.cache/
|
/.cache/
|
||||||
|
/.venv/
|
||||||
/__pycache__/
|
/__pycache__/
|
||||||
/redmine-copy/
|
/redmine-copy/
|
||||||
/dist/*.tar.gz
|
/dist/*.tar.gz
|
||||||
@@ -7,4 +8,5 @@ redMCP/test.env
|
|||||||
redMCP/vendor/
|
redMCP/vendor/
|
||||||
redMCP/composer.phar
|
redMCP/composer.phar
|
||||||
.env
|
.env
|
||||||
|
semantic_index/.env
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
## Cleanup Notes ~ May 4, 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.
|
||||||
|
- [ ] Stage and commit Helpdesk API patch as a focused unit.
|
||||||
|
- [ ] Stage and commit post-import automation as a focused unit.
|
||||||
|
- [ ] Stage and commit semantic index files as a focused unit.
|
||||||
|
- [ ] Stage and commit redMCP feature updates as a focused unit.
|
||||||
|
- [ ] Stage and commit redmine-communicator skill files (optional split).
|
||||||
|
- [ ] Run targeted syntax/tests for each committed unit.
|
||||||
|
- [ ] Confirm final worktree state and note any intentionally uncommitted files.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -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.
|
||||||
|
|||||||
Executable
+183
@@ -0,0 +1,183 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
Usage:
|
||||||
|
deploy/semantic-index/install.sh [--dry-run] [--apply] [--start] [--no-system] [--skip-deps]
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
--dry-run Print commands that would run. This is the default.
|
||||||
|
--apply Install files, venv, dependencies, env template, and systemd units.
|
||||||
|
--start With --apply, reload systemd and start only semantic-index.service.
|
||||||
|
--no-system Skip sudo/systemd operations. Useful for tests and local validation.
|
||||||
|
--skip-deps Skip venv creation and dependency install.
|
||||||
|
|
||||||
|
The installer never runs backfill, never enables the refresh timer, and never
|
||||||
|
passes --force-rebuild.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
mode=dry-run
|
||||||
|
start_service=0
|
||||||
|
system_ops=1
|
||||||
|
skip_deps=0
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run)
|
||||||
|
mode=dry-run
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--apply)
|
||||||
|
mode=apply
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--start)
|
||||||
|
start_service=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--no-system)
|
||||||
|
system_ops=0
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--skip-deps)
|
||||||
|
skip_deps=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$start_service" -eq 1 && "$mode" != "apply" ]]; then
|
||||||
|
echo "--start requires --apply" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||||
|
install_dir=${SEMANTIC_INDEX_INSTALL_DIR:-/opt/semantic-index}
|
||||||
|
env_file=${SEMANTIC_INDEX_ENV_FILE:-/etc/semantic-index.env}
|
||||||
|
state_dir=${SEMANTIC_INDEX_STATE_DIR:-/var/lib/semantic-index}
|
||||||
|
log_dir=${SEMANTIC_INDEX_LOG_DIR:-/var/log/semantic-index}
|
||||||
|
systemd_dir=${SEMANTIC_INDEX_SYSTEMD_DIR:-/etc/systemd/system}
|
||||||
|
python_bin=${PYTHON:-python3}
|
||||||
|
|
||||||
|
run() {
|
||||||
|
if [[ "$mode" == "dry-run" ]]; then
|
||||||
|
printf 'would run:'
|
||||||
|
printf ' %q' "$@"
|
||||||
|
printf '\n'
|
||||||
|
else
|
||||||
|
"$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_sudo() {
|
||||||
|
if [[ "$system_ops" -eq 0 ]]; then
|
||||||
|
run "$@"
|
||||||
|
else
|
||||||
|
run sudo "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
install_env_template() {
|
||||||
|
if [[ "$mode" == "dry-run" ]]; then
|
||||||
|
echo "would copy env template only if missing: $env_file"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ -e "$env_file" ]]; then
|
||||||
|
echo "keeping existing $env_file"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [[ "$system_ops" -eq 0 ]]; then
|
||||||
|
mkdir -p "$(dirname "$env_file")"
|
||||||
|
cp "$repo_root/deploy/semantic-index/semantic-index.env.example" "$env_file"
|
||||||
|
else
|
||||||
|
sudo install -m 0640 "$repo_root/deploy/semantic-index/semantic-index.env.example" "$env_file"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_next_steps_warning() {
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
Semantic Index installed, but deployment is not complete.
|
||||||
|
|
||||||
|
Required manual steps:
|
||||||
|
1. Edit $env_file and fill real secrets/URLs.
|
||||||
|
2. Start or restart the HTTP service:
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl start semantic-index.service
|
||||||
|
3. Validate:
|
||||||
|
curl -sS http://127.0.0.1:8787/health
|
||||||
|
$install_dir/semantic_index/search.sh "goods return" customer-service 3
|
||||||
|
4. Before enabling scheduled refresh, run:
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS='customer-service=5' $install_dir/semantic_index/refresh.sh
|
||||||
|
$install_dir/semantic_index/refresh.sh --apply
|
||||||
|
5. Create/confirm a Qdrant snapshot before any production-scale backfill.
|
||||||
|
|
||||||
|
The refresh timer was NOT enabled automatically.
|
||||||
|
Do not use --force-rebuild unless you intentionally want to pay to re-embed unchanged documents.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "mode=$mode"
|
||||||
|
echo "install_dir=$install_dir"
|
||||||
|
echo "env_file=$env_file"
|
||||||
|
echo "state_dir=$state_dir"
|
||||||
|
echo "log_dir=$log_dir"
|
||||||
|
|
||||||
|
run_sudo mkdir -p "$install_dir" "$state_dir" "$log_dir" "$systemd_dir"
|
||||||
|
run_sudo rsync -a \
|
||||||
|
--exclude ".env" \
|
||||||
|
--exclude "__pycache__/" \
|
||||||
|
--exclude "*.pyc" \
|
||||||
|
"$repo_root/semantic_index" \
|
||||||
|
"$repo_root/tests" \
|
||||||
|
"$repo_root/docs" \
|
||||||
|
"$repo_root/deploy" \
|
||||||
|
"$repo_root/dist" \
|
||||||
|
"$install_dir/"
|
||||||
|
|
||||||
|
if [[ "$skip_deps" -eq 1 ]]; then
|
||||||
|
echo "skipping venv/dependency install because --skip-deps was used"
|
||||||
|
elif [[ "$mode" == "apply" && "$system_ops" -eq 0 ]]; then
|
||||||
|
run "$python_bin" -m venv "$install_dir/.venv"
|
||||||
|
run "$install_dir/.venv/bin/pip" install openai qdrant-client fastapi uvicorn
|
||||||
|
else
|
||||||
|
run_sudo "$python_bin" -m venv "$install_dir/.venv"
|
||||||
|
run_sudo "$install_dir/.venv/bin/pip" install openai qdrant-client fastapi uvicorn
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_env_template
|
||||||
|
|
||||||
|
run_sudo install -m 0644 "$repo_root/deploy/semantic-index/semantic-index.service" "$systemd_dir/semantic-index.service"
|
||||||
|
run_sudo install -m 0644 "$repo_root/deploy/semantic-index/semantic-index-refresh.service" "$systemd_dir/semantic-index-refresh.service"
|
||||||
|
run_sudo install -m 0644 "$repo_root/deploy/semantic-index/semantic-index-refresh.timer" "$systemd_dir/semantic-index-refresh.timer"
|
||||||
|
|
||||||
|
if [[ "$mode" == "apply" && "$skip_deps" -eq 0 ]]; then
|
||||||
|
"$install_dir/.venv/bin/python" -m py_compile "$install_dir"/semantic_index/*.py
|
||||||
|
"$install_dir/.venv/bin/python" -m unittest discover -s "$install_dir/tests/semantic_index"
|
||||||
|
bash -n "$install_dir/semantic_index/refresh.sh"
|
||||||
|
elif [[ "$mode" == "apply" ]]; then
|
||||||
|
echo "skipping installed-code validation because --skip-deps was used"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$mode" == "apply" && "$start_service" -eq 1 ]]; then
|
||||||
|
if [[ "$system_ops" -eq 0 ]]; then
|
||||||
|
echo "skipping systemctl start because --no-system was used"
|
||||||
|
else
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl start semantic-index.service
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$mode" == "apply" ]]; then
|
||||||
|
print_next_steps_warning
|
||||||
|
fi
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Redmine Semantic Index Rolling Refresh
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/opt/semantic-index
|
||||||
|
EnvironmentFile=/etc/semantic-index.env
|
||||||
|
ExecStart=/bin/bash -lc 'exec /opt/semantic-index/semantic_index/refresh.sh --apply'
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Run Redmine Semantic Index Rolling Refresh
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=10min
|
||||||
|
OnUnitActiveSec=30min
|
||||||
|
Unit=semantic-index-refresh.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Copy to /etc/semantic-index.env and fill secrets on the target host.
|
||||||
|
# Do not commit real values.
|
||||||
|
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
QDRANT_URL=http://qdrant-host:6333
|
||||||
|
QDRANT_API_KEY=
|
||||||
|
QDRANT_COLLECTION=redmine_semantic_sample
|
||||||
|
|
||||||
|
REDMINE_URL=http://redmine-host
|
||||||
|
REDMINE_API_KEY=
|
||||||
|
REDMINE_PROJECT_IDENTIFIER=
|
||||||
|
REDMINE_SAMPLE_LIMIT=500
|
||||||
|
|
||||||
|
SEMANTIC_INDEX_HOST=127.0.0.1
|
||||||
|
SEMANTIC_INDEX_PORT=8787
|
||||||
|
SEMANTIC_INDEX_API_KEY=
|
||||||
|
SEMANTIC_INDEX_REFRESH_STATE_PATH=/var/lib/semantic-index/refresh_state.json
|
||||||
|
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS=customer-service=500,hiring=200,todo-jason=200,sales-inbox=100,business-development=100,dock-scheduling=100,prep-standardization=100
|
||||||
|
SEMANTIC_INDEX_LOG_DIR=/var/log/semantic-index
|
||||||
|
SEMANTIC_INDEX_STATE_PATH=/var/lib/semantic-index/refresh_state.json
|
||||||
|
SEMANTIC_INDEX_OVERLAP_MINUTES=15
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Redmine Semantic Index HTTP API
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
WorkingDirectory=/opt/semantic-index
|
||||||
|
EnvironmentFile=/etc/semantic-index.env
|
||||||
|
ExecStart=/bin/bash -lc 'exec /opt/semantic-index/.venv/bin/uvicorn semantic_index.app:app --host "${SEMANTIC_INDEX_HOST}" --port "${SEMANTIC_INDEX_PORT}"'
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
## Distribution Files
|
||||||
|
|
||||||
|
This folder contains packaged copies of the plugins and scripts built by this project for easier distribution.
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# redmine_contacts_helpdesk 3.0.9 Helpdesk Issue API Local Patch
|
||||||
|
|
||||||
|
- Patch set: `redmine_contacts_helpdesk-3.0.9-local-helpdesk-issue-api-20260425T094236Z`
|
||||||
|
- Created: `2026-04-25T09:42:36Z`
|
||||||
|
- Purpose: production install manifest for the local `include=helpdesk` issue
|
||||||
|
API extension.
|
||||||
|
|
||||||
|
## Files To Install
|
||||||
|
|
||||||
|
```text
|
||||||
|
plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb
|
||||||
|
plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb
|
||||||
|
plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb
|
||||||
|
plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
`GET /issues/:id.json?include=journals,helpdesk` keeps the normal Redmine issue
|
||||||
|
API response and adds Helpdesk ticket/contact metadata when the issue is also a
|
||||||
|
Helpdesk ticket. Ordinary issues must continue to respond successfully.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Local checks:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ruby tests/redmine_contacts_helpdesk/test_issue_api_serializer.rb
|
||||||
|
ruby -c plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb
|
||||||
|
ruby -c plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb
|
||||||
|
ruby -c plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb
|
||||||
|
```
|
||||||
|
|
||||||
|
LAN validation on `192.168.50.170` passed on 2026-04-25:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/issues/39779.json?include=journals,helpdesk
|
||||||
|
helpdesk_ticket.contact.id = 1890
|
||||||
|
helpdesk_ticket.contact.name = Callum Mackeonis
|
||||||
|
helpdesk_ticket.contact.email = callum@safetagtracking.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Production install and rollback details are documented in
|
||||||
|
`docs/redmine_issue_api_helpdesk_include.md`.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Semantic Index V1 Pre-Deployment Manifest
|
||||||
|
|
||||||
|
- Patch set: `semantic-index-v1-predeployment-20260425T150000Z`
|
||||||
|
- Created: `2026-04-25T15:00:00Z`
|
||||||
|
- Purpose: deployment manifest for the Redmine semantic index service and its
|
||||||
|
LAN/production preparation docs.
|
||||||
|
|
||||||
|
## Files To Install
|
||||||
|
|
||||||
|
```text
|
||||||
|
semantic_index/
|
||||||
|
tests/semantic_index/
|
||||||
|
deploy/semantic-index/
|
||||||
|
docs/semantic_index_deployment_runbook.md
|
||||||
|
docs/semantic_index_production_notes.md
|
||||||
|
docs/semantic_index_predeployment_validation.md
|
||||||
|
docs/redmine_issue_api_helpdesk_include.md
|
||||||
|
dist/semantic-index-v1-predeployment-20260425T150000Z.MANIFEST.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Not To Install
|
||||||
|
|
||||||
|
```text
|
||||||
|
semantic_index/.env
|
||||||
|
.cache/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep runtime secrets in `semantic_index/.env` or in the service manager
|
||||||
|
environment on the target host. Do not commit or copy local secrets into a
|
||||||
|
source bundle.
|
||||||
|
|
||||||
|
## External Dependencies
|
||||||
|
|
||||||
|
- Redmine Helpdesk API patch documented in
|
||||||
|
`docs/redmine_issue_api_helpdesk_include.md`
|
||||||
|
- Qdrant reachable through `QDRANT_URL`
|
||||||
|
- OpenAI API key for `text-embedding-3-small`
|
||||||
|
- Python packages: `openai`, `qdrant-client`, `fastapi`, `uvicorn`
|
||||||
|
|
||||||
|
## Validation Commands
|
||||||
|
|
||||||
|
```sh
|
||||||
|
deploy/semantic-index/install.sh
|
||||||
|
.venv/bin/python -m py_compile semantic_index/*.py
|
||||||
|
.venv/bin/python -m unittest discover -s tests/semantic_index
|
||||||
|
bash -n semantic_index/refresh.sh
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS='customer-service=5' semantic_index/refresh.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Before any production backfill, follow
|
||||||
|
`docs/semantic_index_deployment_runbook.md` and confirm Qdrant snapshot or
|
||||||
|
volume rollback is available.
|
||||||
|
|
||||||
|
## Operational Rules
|
||||||
|
|
||||||
|
- Run `semantic_index/refresh.sh` in dry-run mode before `--apply`.
|
||||||
|
- Do not schedule `--force-rebuild`; keep it manual-only.
|
||||||
|
- Review refresh logs for `detail_fetched_issues`, `would_embed_documents`, and
|
||||||
|
`embedded_documents`.
|
||||||
|
- Bind HTTP to localhost unless LAN access is explicitly required and protected
|
||||||
|
with `SEMANTIC_INDEX_API_KEY`.
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Redmine Issue API Helpdesk Include Patch
|
||||||
|
|
||||||
|
This repository carries a local RedmineUP Helpdesk API extension so external
|
||||||
|
indexers can keep Redmine issues as the canonical object while still seeing
|
||||||
|
Helpdesk customer metadata.
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
`GET /issues/:id.json?include=journals,helpdesk` returns the normal Redmine
|
||||||
|
issue API payload. When the issue also has a Helpdesk ticket, the issue object
|
||||||
|
includes:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"helpdesk_ticket": {
|
||||||
|
"id": 35159,
|
||||||
|
"contact_id": 1890,
|
||||||
|
"message_id": "...",
|
||||||
|
"source": 0,
|
||||||
|
"is_incoming": true,
|
||||||
|
"from_address": "customer@example.com",
|
||||||
|
"to_address": "contact@ldrprep.com",
|
||||||
|
"cc_address": "",
|
||||||
|
"ticket_date": "2026-04-14T10:18:38Z",
|
||||||
|
"contact": {
|
||||||
|
"id": 1890,
|
||||||
|
"name": "Customer Name",
|
||||||
|
"company": "Customer Company",
|
||||||
|
"email": "customer@example.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For ordinary non-Helpdesk issues, callers must tolerate either
|
||||||
|
`"helpdesk_ticket": null` or an omitted `helpdesk_ticket` field. The semantic
|
||||||
|
indexer treats both as ordinary issue data with no Helpdesk contact metadata.
|
||||||
|
|
||||||
|
## Patch Locations
|
||||||
|
|
||||||
|
- `plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb`
|
||||||
|
overrides Redmine 3.4.4's issue API view and adds the optional
|
||||||
|
`include=helpdesk` block.
|
||||||
|
- `plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb`
|
||||||
|
serializes Helpdesk ticket/contact fields without changing controller logic.
|
||||||
|
- `plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb`
|
||||||
|
loads the serializer during plugin preparation.
|
||||||
|
|
||||||
|
## Production Install Checklist
|
||||||
|
|
||||||
|
Install these files into the production Redmine tree, preserving paths:
|
||||||
|
|
||||||
|
```text
|
||||||
|
plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb
|
||||||
|
plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb
|
||||||
|
plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb
|
||||||
|
plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Before copying, create a production rollback directory and preserve any existing
|
||||||
|
versions of those files:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
stamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||||
|
backup="$HOME/redmine-plugin-backups/helpdesk-issue-api-include-$stamp"
|
||||||
|
mkdir -p "$backup"
|
||||||
|
cd /usr/share/redmine
|
||||||
|
for path in \
|
||||||
|
plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb \
|
||||||
|
plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb \
|
||||||
|
plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb \
|
||||||
|
plugins/redmine_contacts_helpdesk/LOCAL_CHANGELOG.md
|
||||||
|
do
|
||||||
|
if [ -e "$path" ]; then
|
||||||
|
mkdir -p "$backup/$(dirname "$path")"
|
||||||
|
cp -a "$path" "$backup/$path"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
printf 'backup=%s\n' "$backup"
|
||||||
|
```
|
||||||
|
|
||||||
|
After copying, run syntax checks on the production host:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /usr/share/redmine
|
||||||
|
ruby -c plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk.rb
|
||||||
|
ruby -c plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer.rb
|
||||||
|
ruby -c plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload Passenger:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /usr/share/redmine
|
||||||
|
touch tmp/restart.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Then validate one known Helpdesk issue and one ordinary issue:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS -H "X-Redmine-API-Key: $REDMINE_API_KEY" \
|
||||||
|
"$REDMINE_URL/issues/39779.json?include=journals,helpdesk" | jq '.issue.helpdesk_ticket'
|
||||||
|
|
||||||
|
curl -sS -H "X-Redmine-API-Key: $REDMINE_API_KEY" \
|
||||||
|
"$REDMINE_URL/issues/<non_helpdesk_issue_id>.json?include=helpdesk" | jq '.issue.helpdesk_ticket'
|
||||||
|
```
|
||||||
|
|
||||||
|
The Helpdesk issue must include a ticket object with `contact.id`,
|
||||||
|
`contact.name`, and `contact.email`. The non-Helpdesk issue should not error;
|
||||||
|
`helpdesk_ticket` may be `null` or absent.
|
||||||
|
|
||||||
|
Rollback is copying the backup files over the deployed files and touching
|
||||||
|
`tmp/restart.txt` again. If a file did not exist before deployment, remove it
|
||||||
|
during rollback.
|
||||||
|
|
||||||
|
## Reapplying After Redmine Core Upgrade
|
||||||
|
|
||||||
|
1. Compare the upgraded Redmine `app/views/issues/show.api.rsb` against this
|
||||||
|
repository's override.
|
||||||
|
2. Copy any new upstream issue API fields into the plugin override.
|
||||||
|
3. Keep the `include_in_api_response?('helpdesk')` block after the issue
|
||||||
|
timestamps and before optional child/attachment/relation/journal sections.
|
||||||
|
4. Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ruby -c plugins/redmine_contacts_helpdesk/app/views/issues/show.api.rsb
|
||||||
|
ruby tests/redmine_contacts_helpdesk/test_issue_api_serializer.rb
|
||||||
|
```
|
||||||
|
|
||||||
|
5. On the LAN test instance, confirm:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -H "X-Redmine-API-Key: $REDMINE_API_KEY" \
|
||||||
|
"http://192.168.50.170/issues/39779.json?include=journals,helpdesk"
|
||||||
|
```
|
||||||
|
|
||||||
|
The response should include `helpdesk_ticket.contact.id`,
|
||||||
|
`helpdesk_ticket.contact.name`, and `helpdesk_ticket.contact.email` for a
|
||||||
|
known Helpdesk issue.
|
||||||
|
|
||||||
|
## LAN Test Result
|
||||||
|
|
||||||
|
On 2026-04-25, the patch was deployed to the LAN Redmine copy at
|
||||||
|
`192.168.50.170`.
|
||||||
|
|
||||||
|
- Remote backup:
|
||||||
|
`/home/reddev/redmine-plugin-backups/helpdesk-issue-api-include-20260425T094236Z`
|
||||||
|
- Syntax checks passed on the LAN host for the loader, serializer, and API view.
|
||||||
|
- `GET /issues/39779.json?include=journals,helpdesk` returned contact
|
||||||
|
`#1890 Callum Mackeonis <callum@safetagtracking.com>`.
|
||||||
|
- `semantic_index inspect preview-redmine --limit 3 --project customer-service`
|
||||||
|
showed contact metadata on issue, journal, and contact chunks before Qdrant
|
||||||
|
rebuild.
|
||||||
@@ -32,32 +32,77 @@ 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-04-25 - redMCP Native Search, Filtering, And MCP Operations
|
||||||
|
|
||||||
- Touched areas:
|
- Touched areas:
|
||||||
- `redMCP`
|
- `redMCP`
|
||||||
- Purpose:
|
- Purpose:
|
||||||
- Make Redmine's existing issue filtering and built-in text search explicit
|
- Make Redmine's existing issue filtering and built-in text search explicit
|
||||||
before adding external search infrastructure.
|
before adding external search infrastructure.
|
||||||
- Make redMCP runnable as a stdio MCP server for live client testing.
|
- Make redMCP runnable as an MCP server for live client testing.
|
||||||
|
- Make the network MCP server easier to debug and restart during local tests.
|
||||||
- Behavior changed:
|
- Behavior changed:
|
||||||
- Added `filterIssues()` as a named alias for Redmine's `/issues.json`
|
- Added `filterIssues()` as a named alias for Redmine's `/issues.json`
|
||||||
filtering.
|
filtering.
|
||||||
- Added `search()` for Redmine's built-in `/search.json` endpoint.
|
- Added `search()` for Redmine's built-in `/search.json` endpoint.
|
||||||
- Added `searchIssues()` for issue-only Redmine text search.
|
- Added `searchIssues()` for issue-only Redmine text search.
|
||||||
- Added `redMCP/bin/redmcp-server.php`, a dependency-light stdio MCP server
|
- Added `projects()`, `listProjects()`, and `project()` for Redmine's
|
||||||
that exposes Redmine filtering/search, issue CRUD, Helpdesk-aware reads, and
|
`/projects.json` APIs.
|
||||||
explicit Helpdesk response tools.
|
- Added `users()`, `listUsers()`, `user()`, and `projectMemberships()` for
|
||||||
- Registered the MCP server as a Composer `bin` entry.
|
Redmine's user and membership APIs.
|
||||||
|
- Added `ListQueryNormalizer` so MCP list tools accept friendly paging,
|
||||||
|
sorting, status, and date options while preserving raw Redmine
|
||||||
|
`filters`/`params` overrides.
|
||||||
|
- Added `redMCP/bin/test-query-normalizer.php` for no-network checks of
|
||||||
|
Redmine query parameter normalization.
|
||||||
|
- Added a shared MCP dispatcher and transport-specific server wrappers.
|
||||||
|
- Added `redMCP/bin/redmcp-server.php` for stdio MCP clients.
|
||||||
|
- Added `redMCP/bin/redmcp-http-server.php` for bearer-token-protected
|
||||||
|
Streamable HTTP network clients on `/mcp`.
|
||||||
|
- Added PID/status/stop handling to the HTTP server.
|
||||||
|
- Added optional full-argument JSONL debug logging via `--debug-log` or
|
||||||
|
`MCP_DEBUG_LOG`.
|
||||||
|
- Added recursive credential redaction for MCP tool output and debug logs.
|
||||||
|
- Added `redMCP/bin/generate-bearer-token.php`.
|
||||||
|
- Both transports expose Redmine project reads, users, project memberships,
|
||||||
|
filtering/search, issue CRUD, Helpdesk-aware reads, and explicit Helpdesk
|
||||||
|
response tools.
|
||||||
|
- Registered all MCP helper commands as Composer `bin` entries.
|
||||||
- LAN test result:
|
- LAN test result:
|
||||||
- `php -l redMCP/app/RedmineClient.php` passed.
|
- `php -l redMCP/app/RedmineClient.php` passed.
|
||||||
- `php -l redMCP/bin/redmcp-server.php` passed.
|
- `php -l redMCP/bin/redmcp-server.php` passed.
|
||||||
|
- `php -l redMCP/bin/redmcp-http-server.php` passed.
|
||||||
|
- `php -l redMCP/bin/generate-bearer-token.php` passed.
|
||||||
- `composer validate --working-dir=redMCP` passed; Composer emitted PHP 8.5
|
- `composer validate --working-dir=redMCP` passed; Composer emitted PHP 8.5
|
||||||
deprecation notices from system Composer dependencies.
|
deprecation notices from system Composer dependencies.
|
||||||
- Live stdio MCP framing test passed for `initialize`, `tools/list`, and
|
- Live stdio MCP framing test passed for `initialize`, `tools/list`, and
|
||||||
`tools/call` using `redmine_search_issues` against `fud-helpdesk`.
|
`tools/call` using `redmine_search_issues` against `fud-helpdesk`.
|
||||||
- The live MCP tool call returned two issue search results from seven total
|
- Live Streamable HTTP test passed for authenticated `initialize`,
|
||||||
for `redMCP-smoke`.
|
`tools/list`, and `tools/call` using `redmine_search_issues`.
|
||||||
|
- `redmcp-http-server.php` refused to start without `MCP_SERVER_TOKEN`.
|
||||||
|
- Unauthenticated `/mcp` returned `401`; wrong path returned `404`.
|
||||||
|
- HTTP PID helpers reported stopped/running states, rejected a duplicate
|
||||||
|
start, stopped the live process, detected a stale PID file, and started
|
||||||
|
with `--force`.
|
||||||
|
- Live Streamable HTTP tests passed for `redmine_list_users`,
|
||||||
|
`redmine_get_user`, and `redmine_list_project_memberships`.
|
||||||
|
- `redmine_get_user` redacted the returned Redmine `api_key` field.
|
||||||
|
- `redmine_list_project_memberships` returned direct and inherited
|
||||||
|
memberships for `customer-service`; `fud-helpdesk` returned a valid empty
|
||||||
|
membership list.
|
||||||
|
- `php redMCP/bin/test-query-normalizer.php` passed with coverage for paging,
|
||||||
|
sort shortcuts, status aliases, date presets/ranges, free-text dates, and
|
||||||
|
raw override precedence.
|
||||||
|
- Live Streamable HTTP tests passed for friendly `redmine_list_issues`,
|
||||||
|
`redmine_search_issues`, `redmine_list_users`, `redmine_list_projects`, and
|
||||||
|
`redmine_list_project_memberships` arguments.
|
||||||
|
- Debug logging wrote JSONL records with full project-tool arguments and did
|
||||||
|
not include the bearer token, `Authorization`, or Redmine API key.
|
||||||
|
- Token generation passed default, `--bytes 48`, and `--env-line` modes.
|
||||||
|
- `redmine_list_projects` returned three projects from 117 total.
|
||||||
|
- `redmine_get_project` returned `fud-helpdesk` by identifier and by id 117.
|
||||||
|
- The live MCP tool calls returned issue search results from seven total for
|
||||||
|
`redMCP-smoke`.
|
||||||
|
|
||||||
## 2026-04-25 - Test Helpdesk Credential Sanitization
|
## 2026-04-25 - Test Helpdesk Credential Sanitization
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,336 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
## 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://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
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended production-style refresh overrides:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS='customer-service=500,hiring=200,todo-jason=200,sales-inbox=100,business-development=100,dock-scheduling=100,prep-standardization=100'
|
||||||
|
SEMANTIC_INDEX_LOG_DIR=/var/log/semantic-index
|
||||||
|
SEMANTIC_INDEX_STATE_PATH=/var/lib/semantic-index/refresh_state.json
|
||||||
|
SEMANTIC_INDEX_OVERLAP_MINUTES=15
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `SEMANTIC_INDEX_API_KEY` set when binding outside localhost. Do not commit
|
||||||
|
API keys or `.env` files.
|
||||||
|
|
||||||
|
## Systemd Templates
|
||||||
|
|
||||||
|
Templates live in `deploy/semantic-index/`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
install.sh
|
||||||
|
semantic-index.service
|
||||||
|
semantic-index-refresh.service
|
||||||
|
semantic-index-refresh.timer
|
||||||
|
semantic-index.env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the installer first. It defaults to dry-run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
deploy/semantic-index/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the install:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
deploy/semantic-index/install.sh --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally start only the HTTP service after installing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
deploy/semantic-index/install.sh --apply --start
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer creates `/opt/semantic-index`, `/var/lib/semantic-index`, and
|
||||||
|
`/var/log/semantic-index`; copies the deploy unit; creates
|
||||||
|
`/etc/semantic-index.env` only if it does not already exist; installs systemd
|
||||||
|
unit files; and runs local validation. It does not run backfill, does not enable
|
||||||
|
the refresh timer, and never passes `--force-rebuild`.
|
||||||
|
|
||||||
|
Manual install shape, if the installer cannot be used:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo mkdir -p /opt/semantic-index /var/lib/semantic-index /var/log/semantic-index
|
||||||
|
sudo rsync -a \
|
||||||
|
--exclude '.env' \
|
||||||
|
--exclude '__pycache__/' \
|
||||||
|
--exclude '*.pyc' \
|
||||||
|
semantic_index tests docs deploy dist /opt/semantic-index/
|
||||||
|
sudo cp deploy/semantic-index/semantic-index.env.example /etc/semantic-index.env
|
||||||
|
sudo install -m 0644 deploy/semantic-index/semantic-index.service /etc/systemd/system/semantic-index.service
|
||||||
|
sudo install -m 0644 deploy/semantic-index/semantic-index-refresh.service /etc/systemd/system/semantic-index-refresh.service
|
||||||
|
sudo install -m 0644 deploy/semantic-index/semantic-index-refresh.timer /etc/systemd/system/semantic-index-refresh.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
After editing `/etc/semantic-index.env`, validate manually before enabling the
|
||||||
|
timer:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl start semantic-index.service
|
||||||
|
sudo systemctl status semantic-index.service
|
||||||
|
sudo systemctl start semantic-index-refresh.service
|
||||||
|
sudo journalctl -u semantic-index-refresh.service -n 100 --no-pager
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable the timer only after manual dry-run and `--apply` logs look normal:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl enable --now semantic-index-refresh.timer
|
||||||
|
```
|
||||||
|
|
||||||
|
## Initial Validation
|
||||||
|
|
||||||
|
Run syntax and test checks after copying code:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m py_compile semantic_index/*.py
|
||||||
|
.venv/bin/python -m unittest discover -s tests/semantic_index
|
||||||
|
bash -n semantic_index/refresh.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm service startup:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uvicorn semantic_index.app:app --host 127.0.0.1 --port 8787
|
||||||
|
curl -sS http://127.0.0.1:8787/health
|
||||||
|
```
|
||||||
|
|
||||||
|
If `SEMANTIC_INDEX_API_KEY` is set:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS -H "Authorization: Bearer $SEMANTIC_INDEX_API_KEY" \
|
||||||
|
http://127.0.0.1:8787/projects
|
||||||
|
```
|
||||||
|
|
||||||
|
## Initial Backfill
|
||||||
|
|
||||||
|
Preview Redmine mapping before writing to Qdrant:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m semantic_index inspect preview-redmine \
|
||||||
|
--project customer-service \
|
||||||
|
--limit 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Backfill the current balanced sample:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m semantic_index --backfill-redmine-projects \
|
||||||
|
--project-limits customer-service=500,hiring=200,todo-jason=200,sales-inbox=100,business-development=100,dock-scheduling=100,prep-standardization=100
|
||||||
|
```
|
||||||
|
|
||||||
|
Audit the result:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m semantic_index inspect audit --source redmine --limit 5000
|
||||||
|
.venv/bin/python -m semantic_index inspect smoke-search --project customer-service
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected broad shape for the current LAN sample is roughly:
|
||||||
|
|
||||||
|
- Customer Service is the largest project.
|
||||||
|
- Helpdesk tickets have contact metadata.
|
||||||
|
- Internal projects may have no Helpdesk contact metadata.
|
||||||
|
- `attachments=0`.
|
||||||
|
|
||||||
|
## Routine Refresh
|
||||||
|
|
||||||
|
Use the wrapper for production-style refresh. It defaults to dry-run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
semantic_index/refresh.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Small smoke check:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS='customer-service=5' semantic_index/refresh.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply refresh manually:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
semantic_index/refresh.sh --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Installed wrappers can also be called by absolute path, for example
|
||||||
|
`/opt/semantic-index/semantic_index/refresh.sh`. The wrapper uses its own
|
||||||
|
install root as the working directory and reads defaults from
|
||||||
|
`/etc/semantic-index.env` when that file is readable.
|
||||||
|
|
||||||
|
Review the log path printed by the wrapper. For a healthy routine run after
|
||||||
|
state exists, expect:
|
||||||
|
|
||||||
|
- `scanned_issues` greater than or equal to `detail_fetched_issues`
|
||||||
|
- old issues counted under `skipped_issues`
|
||||||
|
- `would_embed_documents` and `embedded_documents` near zero when Redmine has
|
||||||
|
not changed
|
||||||
|
- no scheduled use of `--force-rebuild`
|
||||||
|
|
||||||
|
Only schedule the wrapper after manual dry-run and apply logs look normal.
|
||||||
|
|
||||||
|
Cron shape, when ready:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
*/30 * * * * cd /home/iadnah/redmine && semantic_index/refresh.sh --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
## Search Validation
|
||||||
|
|
||||||
|
HTTP search:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
semantic_index/search.sh "goods return" customer-service 3
|
||||||
|
semantic_index/search.sh "candidate follow up" hiring 5
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI inspection:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m semantic_index inspect search "goods return" \
|
||||||
|
--project customer-service \
|
||||||
|
--limit 3
|
||||||
|
|
||||||
|
.venv/bin/python -m semantic_index inspect list \
|
||||||
|
--source redmine \
|
||||||
|
--project customer-service \
|
||||||
|
--limit 10
|
||||||
|
```
|
||||||
|
|
||||||
|
MCP stdio:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m semantic_index --mcp-stdio
|
||||||
|
```
|
||||||
|
|
||||||
|
Available tools:
|
||||||
|
|
||||||
|
- `semantic_search`
|
||||||
|
- `semantic_get_document`
|
||||||
|
- `semantic_list_projects`
|
||||||
|
- `semantic_backfill_redmine_sample`
|
||||||
|
- `semantic_refresh_redmine`
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Code rollback:
|
||||||
|
|
||||||
|
- Stop `uvicorn` or the service manager unit.
|
||||||
|
- Restore the previous `semantic_index/` code.
|
||||||
|
- Restore the previous Redmine Helpdesk plugin patch if contact metadata broke.
|
||||||
|
- Restart the service.
|
||||||
|
|
||||||
|
Index rollback options:
|
||||||
|
|
||||||
|
- Restore a Qdrant snapshot or preserved Docker volume.
|
||||||
|
- Or rebuild from Redmine with the known-good code using the multi-project
|
||||||
|
backfill command above.
|
||||||
|
|
||||||
|
Refresh rollback:
|
||||||
|
|
||||||
|
- Disable cron/systemd schedule if enabled.
|
||||||
|
- Preserve the failing log file for diagnosis.
|
||||||
|
- If the refresh state is wrong, move the state file aside rather than editing
|
||||||
|
it in place:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mv .cache/semantic_index/refresh_state.json .cache/semantic_index/refresh_state.json.bad
|
||||||
|
```
|
||||||
|
|
||||||
|
The next refresh will behave like a first refresh for state purposes, while the
|
||||||
|
`source_hash` guard still prevents embedding unchanged documents.
|
||||||
|
|
||||||
|
## Production Readiness Checklist
|
||||||
|
|
||||||
|
- Redmine API key is scoped appropriately and stored outside git.
|
||||||
|
- Qdrant URL and collection are confirmed.
|
||||||
|
- Qdrant snapshot/export path is known.
|
||||||
|
- Helpdesk API patch is deployed and validated.
|
||||||
|
- HTTP service is bound only to trusted localhost/LAN as intended.
|
||||||
|
- `SEMANTIC_INDEX_API_KEY` is set for non-localhost use.
|
||||||
|
- Initial backfill audit and smoke searches pass.
|
||||||
|
- Refresh dry-run and apply logs show expected low embedding counts.
|
||||||
|
- `--force-rebuild` is documented as manual-only.
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
# Semantic Index Pre-Deployment Validation
|
||||||
|
|
||||||
|
Validation date: `2026-04-25`
|
||||||
|
|
||||||
|
This records the current LAN pre-deployment checks for the semantic index. It
|
||||||
|
does not include secrets.
|
||||||
|
|
||||||
|
## Deploy Unit
|
||||||
|
|
||||||
|
Semantic-index deployable files are documented in:
|
||||||
|
|
||||||
|
- `dist/semantic-index-v1-predeployment-20260425T150000Z.MANIFEST.md`
|
||||||
|
- `docs/semantic_index_deployment_runbook.md`
|
||||||
|
|
||||||
|
Current known unrelated worktree changes are outside the semantic-index deploy
|
||||||
|
unit and should not be mixed into the semantic-index release package:
|
||||||
|
|
||||||
|
- `redMCP/README.md`
|
||||||
|
- `redMCP/app/McpDispatcher.php`
|
||||||
|
- `redMCP/app/RedmineClient.php`
|
||||||
|
- `redMCP/composer.json`
|
||||||
|
- `redMCP/bin/test-redmine-structure.php`
|
||||||
|
- `TODO.md`
|
||||||
|
|
||||||
|
## Local Verification
|
||||||
|
|
||||||
|
Passed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m py_compile semantic_index/*.py
|
||||||
|
.venv/bin/python -m unittest discover -s tests/semantic_index
|
||||||
|
bash -n semantic_index/refresh.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed semantic test result:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ran 65 tests in 1.041s
|
||||||
|
OK
|
||||||
|
```
|
||||||
|
|
||||||
|
## LAN Redmine Preview
|
||||||
|
|
||||||
|
Passed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m semantic_index inspect preview-redmine \
|
||||||
|
--project customer-service \
|
||||||
|
--limit 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
- Helpdesk issue chunks include contact id, name, email, and company metadata.
|
||||||
|
- Issue `39779` includes Callum Mackeonis and `callum@safetagtracking.com`.
|
||||||
|
- Journals are present as separate indexed documents.
|
||||||
|
- Contact documents are present as separate indexed documents.
|
||||||
|
|
||||||
|
## Qdrant Audit
|
||||||
|
|
||||||
|
Passed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
.venv/bin/python -m semantic_index inspect audit --source redmine --limit 5000 --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
```text
|
||||||
|
total_documents=2947
|
||||||
|
doc_type contact=714
|
||||||
|
doc_type issue=1208
|
||||||
|
doc_type journal=1025
|
||||||
|
project business-development=66
|
||||||
|
project customer-service=1684
|
||||||
|
project dock-scheduling=63
|
||||||
|
project hiring=409
|
||||||
|
project prep-standardization=25
|
||||||
|
project sales-inbox=192
|
||||||
|
project todo-jason=508
|
||||||
|
contact_metadata=2232
|
||||||
|
helpdesk_contact_metadata=2232/2232
|
||||||
|
attachments=0
|
||||||
|
```
|
||||||
|
|
||||||
|
## HTTP Validation
|
||||||
|
|
||||||
|
Passed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS http://127.0.0.1:8787/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status":"ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Unauthenticated `/projects` correctly returned unauthorized when
|
||||||
|
`SEMANTIC_INDEX_API_KEY` was configured.
|
||||||
|
|
||||||
|
Authenticated `/projects` passed and returned the expected seven projects:
|
||||||
|
|
||||||
|
```text
|
||||||
|
business-development
|
||||||
|
customer-service
|
||||||
|
dock-scheduling
|
||||||
|
hiring
|
||||||
|
prep-standardization
|
||||||
|
sales-inbox
|
||||||
|
todo-jason
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP search passed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
semantic_index/search.sh "goods return" customer-service 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
- Top result was `redmine:issue:39779:chunk:0`.
|
||||||
|
- Citation included project `customer-service`.
|
||||||
|
- Citation included contact id `1890`, contact name, contact email, and Redmine
|
||||||
|
URL.
|
||||||
|
|
||||||
|
## Refresh Validation
|
||||||
|
|
||||||
|
Passed safe dry-run smoke check:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS='customer-service=5' semantic_index/refresh.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Observed:
|
||||||
|
|
||||||
|
```text
|
||||||
|
mode=dry-run
|
||||||
|
issues=5
|
||||||
|
scanned_issues=5
|
||||||
|
detail_fetched_issues=0
|
||||||
|
skipped_issues=5
|
||||||
|
would_embed_documents=0
|
||||||
|
embedded_documents=0
|
||||||
|
```
|
||||||
|
|
||||||
|
This confirms the refresh state prefilter skips old issues before Redmine detail
|
||||||
|
fetch and before embedding.
|
||||||
|
|
||||||
|
## Qdrant Validation
|
||||||
|
|
||||||
|
Read-only collection check passed:
|
||||||
|
|
||||||
|
```text
|
||||||
|
collection=redmine_semantic_sample
|
||||||
|
status=green
|
||||||
|
vector_size=1536
|
||||||
|
distance=Cosine
|
||||||
|
points_count=2947
|
||||||
|
update_queue.length=0
|
||||||
|
```
|
||||||
|
|
||||||
|
Read-only snapshot listing endpoint responded successfully:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/collections/redmine_semantic_sample/snapshots
|
||||||
|
result=[]
|
||||||
|
```
|
||||||
|
|
||||||
|
No snapshot was created during this validation.
|
||||||
|
|
||||||
|
## Remaining Pre-Deployment Items
|
||||||
|
|
||||||
|
- Decide final target host paths for logs and refresh state.
|
||||||
|
- Decide service manager shape: manual `uvicorn`, systemd service, or another
|
||||||
|
supervisor.
|
||||||
|
- Create or confirm a Qdrant snapshot immediately before production backfill.
|
||||||
|
- Package only the semantic-index deploy unit, keeping unrelated `redMCP`
|
||||||
|
worktree changes out of the release.
|
||||||
|
- Keep scheduled refresh disabled until manual dry-run and `--apply` logs are
|
||||||
|
reviewed on the target host.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
For production-style deployment, use `/opt/semantic-index` for code,
|
||||||
|
`/etc/semantic-index.env` for service environment, `/var/lib/semantic-index`
|
||||||
|
for refresh state, and `/var/log/semantic-index` for refresh logs. Systemd
|
||||||
|
templates live in `deploy/semantic-index/`.
|
||||||
|
|
||||||
|
## Embedding Cost Guard
|
||||||
|
|
||||||
|
Normal refresh embeds only documents that are new or whose Redmine-derived
|
||||||
|
`source_hash` changed. Unchanged documents are left alone. Stale indexed
|
||||||
|
documents for refreshed issues are deleted without embedding.
|
||||||
|
|
||||||
|
Do not schedule `--force-rebuild`. Use it only as a manual maintenance action
|
||||||
|
when intentionally re-embedding unchanged documents.
|
||||||
|
|
||||||
|
## Cron Shape
|
||||||
|
|
||||||
|
A later cron entry can call the same wrapper:
|
||||||
|
|
||||||
|
```cron
|
||||||
|
*/30 * * * * cd /home/iadnah/redmine && semantic_index/refresh.sh --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Before adding a real schedule, run the wrapper manually and confirm the log
|
||||||
|
shows expected `embedded_documents`, `unchanged_documents`, and
|
||||||
|
`skipped_issues` counts.
|
||||||
|
|
||||||
|
For a quick wrapper smoke check, reduce the project limits:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS='customer-service=5' semantic_index/refresh.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
After refresh state exists, routine dry-runs should show old issues as
|
||||||
|
`skipped_issues` without matching `detail_fetched_issues`. That indicates the
|
||||||
|
refresh is avoiding unnecessary Redmine detail requests before it reaches the
|
||||||
|
embedding cost guard.
|
||||||
@@ -15,6 +15,92 @@ redMCP testing.
|
|||||||
- Mailpit ports: HTTP `8025`, SMTP `1025`, POP3 `1110`
|
- Mailpit ports: HTTP `8025`, SMTP `1025`, POP3 `1110`
|
||||||
- POP3 credentials: `test` / `testpass`
|
- POP3 credentials: `test` / `testpass`
|
||||||
- SMTP authentication: none
|
- SMTP authentication: none
|
||||||
|
- Shared scratch path: `/opt/lanscratch`
|
||||||
|
- Post-import payload path:
|
||||||
|
`/opt/lanscratch/redmine-post-import/repo`
|
||||||
|
- Post-import status path:
|
||||||
|
`/opt/lanscratch/redmine-post-import/status`
|
||||||
|
|
||||||
|
## Automated Daily Post-Import
|
||||||
|
|
||||||
|
Stage the post-import payload from this host into the shared LAN scratch folder:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./stage_post_import_payload.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The default staging mode is a dry run. Review the `rsync` command, then apply:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./stage_post_import_payload.py --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
After the fresh production database and `/usr/share/redmine` tree have been
|
||||||
|
copied onto the LAN test host, the test host should run the automation locally:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /opt/lanscratch/redmine-post-import/repo
|
||||||
|
./post_import_refresh.py --local --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
For manual review on the test host, omit `--apply` first:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /opt/lanscratch/redmine-post-import/repo
|
||||||
|
./post_import_refresh.py --local
|
||||||
|
```
|
||||||
|
|
||||||
|
This host can check completion by reading:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/opt/lanscratch/redmine-post-import/status/latest.json
|
||||||
|
/opt/lanscratch/redmine-post-import/status/latest-success.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The automation:
|
||||||
|
|
||||||
|
- verifies the tracked plugin source directories exist locally and that the
|
||||||
|
remote Redmine path exists;
|
||||||
|
- overlays remote dev-only files from `/home/reddev/redmine-dev-overrides` when
|
||||||
|
that directory exists;
|
||||||
|
- reapplies `redmine_event_outbox`, `redmine_contacts`, and
|
||||||
|
`redmine_contacts_helpdesk` from this repository into
|
||||||
|
`/usr/share/redmine/plugins/`;
|
||||||
|
- runs `RAILS_ENV=production bundle exec rake redmine:plugins:migrate`;
|
||||||
|
- fixes group-write permissions on attachment, `tmp`, and `log` paths;
|
||||||
|
- runs `reset_helpdesk_mail_settings.py` unless `--skip-helpdesk-reset` is
|
||||||
|
passed;
|
||||||
|
- restarts Passenger with `touch tmp/restart.txt`;
|
||||||
|
- runs `validate_test_instance.py`;
|
||||||
|
- checks outbox status and dry-runs a small outbox batch;
|
||||||
|
- runs a semantic-index dry-run smoke check only.
|
||||||
|
|
||||||
|
Each applied run writes status JSON under
|
||||||
|
`/opt/lanscratch/redmine-post-import/status/runs/`, updates `latest.json` after
|
||||||
|
each step, and updates `latest-success.json` only after every step exits
|
||||||
|
successfully. The JSON includes the run id, host, execution mode, Redmine path,
|
||||||
|
repo root, failed step when applicable, and per-command return codes.
|
||||||
|
|
||||||
|
Remote write and permission steps use `sudo` by default because a fresh
|
||||||
|
production file copy may leave `/usr/share/redmine` or attachment paths owned by
|
||||||
|
another user. This applies in both local and SSH modes. If the dev host already
|
||||||
|
gives the runner write access to those paths, pass `--no-remote-sudo`.
|
||||||
|
|
||||||
|
The older SSH orchestration path from this host remains available:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./post_import_refresh.py
|
||||||
|
./post_import_refresh.py --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
The automation deliberately does **not** run a semantic-index apply refresh,
|
||||||
|
does **not** use `--force-rebuild`, and does **not** enable the semantic-index
|
||||||
|
refresh timer. After a fresh database clone, treat semantic-index writes or a
|
||||||
|
Qdrant rebuild as a separate manual maintenance action with a snapshot or
|
||||||
|
isolated dev collection first.
|
||||||
|
|
||||||
|
Use the manual sections below for troubleshooting individual steps or for
|
||||||
|
running the sequence by hand.
|
||||||
|
|
||||||
## 1. Validate The Fresh Import
|
## 1. Validate The Fresh Import
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,21 @@ This RedmineUP helpdesk plugin is maintained as local legacy code for the
|
|||||||
installed Redmine 3.4.4 environment. Keep entries focused on local behavior,
|
installed Redmine 3.4.4 environment. Keep entries focused on local behavior,
|
||||||
rollback archives, and LAN test status.
|
rollback archives, and LAN test status.
|
||||||
|
|
||||||
|
## 2026-04-25 - Issue API Helpdesk Contact Include
|
||||||
|
|
||||||
|
- Purpose: let external semantic indexing keep Redmine issues as the canonical
|
||||||
|
object while still receiving Helpdesk ticket/contact metadata.
|
||||||
|
- Touched behavior:
|
||||||
|
- Added `include=helpdesk` support to the Redmine issue API override.
|
||||||
|
- Added a Helpdesk issue API serializer for ticket/customer fields.
|
||||||
|
- Upgrade note: see `docs/redmine_issue_api_helpdesk_include.md` before
|
||||||
|
updating Redmine core issue API views.
|
||||||
|
- LAN test result:
|
||||||
|
- Deployed to `192.168.50.170` with rollback backup
|
||||||
|
`/home/reddev/redmine-plugin-backups/helpdesk-issue-api-include-20260425T094236Z`.
|
||||||
|
- Confirmed `/issues/39779.json?include=journals,helpdesk` returns
|
||||||
|
`helpdesk_ticket.contact` with id, name, company, and email.
|
||||||
|
|
||||||
## 2026-04-21 - Helpdesk Search Read API And Outbox Coverage
|
## 2026-04-21 - Helpdesk Search Read API And Outbox Coverage
|
||||||
|
|
||||||
- Purpose: make helpdesk ticket and message identity first-class for external
|
- Purpose: make helpdesk ticket and message identity first-class for external
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
api.issue do
|
||||||
|
api.id @issue.id
|
||||||
|
api.project(:id => @issue.project_id, :name => @issue.project.name) unless @issue.project.nil?
|
||||||
|
api.tracker(:id => @issue.tracker_id, :name => @issue.tracker.name) unless @issue.tracker.nil?
|
||||||
|
api.status(:id => @issue.status_id, :name => @issue.status.name) unless @issue.status.nil?
|
||||||
|
api.priority(:id => @issue.priority_id, :name => @issue.priority.name) unless @issue.priority.nil?
|
||||||
|
api.author(:id => @issue.author_id, :name => @issue.author.name) unless @issue.author.nil?
|
||||||
|
api.assigned_to(:id => @issue.assigned_to_id, :name => @issue.assigned_to.name) unless @issue.assigned_to.nil?
|
||||||
|
api.category(:id => @issue.category_id, :name => @issue.category.name) unless @issue.category.nil?
|
||||||
|
api.fixed_version(:id => @issue.fixed_version_id, :name => @issue.fixed_version.name) unless @issue.fixed_version.nil?
|
||||||
|
api.parent(:id => @issue.parent_id) unless @issue.parent.nil?
|
||||||
|
|
||||||
|
api.subject @issue.subject
|
||||||
|
api.description @issue.description
|
||||||
|
api.start_date @issue.start_date
|
||||||
|
api.due_date @issue.due_date
|
||||||
|
api.done_ratio @issue.done_ratio
|
||||||
|
api.is_private @issue.is_private
|
||||||
|
api.estimated_hours @issue.estimated_hours
|
||||||
|
api.total_estimated_hours @issue.total_estimated_hours
|
||||||
|
if User.current.allowed_to?(:view_time_entries, @project)
|
||||||
|
api.spent_hours(@issue.spent_hours)
|
||||||
|
api.total_spent_hours(@issue.total_spent_hours)
|
||||||
|
end
|
||||||
|
|
||||||
|
render_api_custom_values @issue.visible_custom_field_values, api
|
||||||
|
|
||||||
|
api.created_on @issue.created_on
|
||||||
|
api.updated_on @issue.updated_on
|
||||||
|
api.closed_on @issue.closed_on
|
||||||
|
|
||||||
|
if include_in_api_response?('helpdesk')
|
||||||
|
helpdesk_ticket = RedmineHelpdesk::IssueApiSerializer.serialize(@issue)
|
||||||
|
if helpdesk_ticket
|
||||||
|
api.helpdesk_ticket do
|
||||||
|
api.id helpdesk_ticket[:id]
|
||||||
|
api.contact_id helpdesk_ticket[:contact_id]
|
||||||
|
api.message_id helpdesk_ticket[:message_id]
|
||||||
|
api.source helpdesk_ticket[:source]
|
||||||
|
api.is_incoming helpdesk_ticket[:is_incoming]
|
||||||
|
api.from_address helpdesk_ticket[:from_address]
|
||||||
|
api.to_address helpdesk_ticket[:to_address]
|
||||||
|
api.cc_address helpdesk_ticket[:cc_address]
|
||||||
|
api.ticket_date helpdesk_ticket[:ticket_date]
|
||||||
|
if helpdesk_ticket[:contact]
|
||||||
|
api.contact do
|
||||||
|
api.id helpdesk_ticket[:contact][:id]
|
||||||
|
api.name helpdesk_ticket[:contact][:name]
|
||||||
|
api.company helpdesk_ticket[:contact][:company]
|
||||||
|
api.email helpdesk_ticket[:contact][:email]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
else
|
||||||
|
api.helpdesk_ticket nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render_api_issue_children(@issue, api) if include_in_api_response?('children')
|
||||||
|
|
||||||
|
api.array :attachments do
|
||||||
|
@issue.attachments.each do |attachment|
|
||||||
|
render_api_attachment(attachment, api)
|
||||||
|
end
|
||||||
|
end if include_in_api_response?('attachments')
|
||||||
|
|
||||||
|
api.array :relations do
|
||||||
|
@relations.each do |relation|
|
||||||
|
api.relation(:id => relation.id, :issue_id => relation.issue_from_id, :issue_to_id => relation.issue_to_id, :relation_type => relation.relation_type, :delay => relation.delay)
|
||||||
|
end
|
||||||
|
end if include_in_api_response?('relations') && @relations.present?
|
||||||
|
|
||||||
|
api.array :changesets do
|
||||||
|
@changesets.each do |changeset|
|
||||||
|
api.changeset :revision => changeset.revision do
|
||||||
|
api.user(:id => changeset.user_id, :name => changeset.user.name) unless changeset.user.nil?
|
||||||
|
api.comments changeset.comments
|
||||||
|
api.committed_on changeset.committed_on
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end if include_in_api_response?('changesets')
|
||||||
|
|
||||||
|
api.array :journals do
|
||||||
|
@journals.each do |journal|
|
||||||
|
api.journal :id => journal.id do
|
||||||
|
api.user(:id => journal.user_id, :name => journal.user.name) unless journal.user.nil?
|
||||||
|
api.notes journal.notes
|
||||||
|
api.created_on journal.created_on
|
||||||
|
api.private_notes journal.private_notes
|
||||||
|
api.array :details do
|
||||||
|
journal.visible_details.each do |detail|
|
||||||
|
api.detail :property => detail.property, :name => detail.prop_key do
|
||||||
|
api.old_value detail.old_value
|
||||||
|
api.new_value detail.value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end if include_in_api_response?('journals')
|
||||||
|
|
||||||
|
api.array :watchers do
|
||||||
|
@issue.watcher_users.each do |user|
|
||||||
|
api.user :id => user.id, :name => user.name
|
||||||
|
end
|
||||||
|
end if include_in_api_response?('watchers') && User.current.allowed_to?(:view_issue_watchers, @issue.project)
|
||||||
|
end
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
ActionDispatch::Callbacks.to_prepare do
|
ActionDispatch::Callbacks.to_prepare do
|
||||||
|
require 'redmine_helpdesk/issue_api_serializer'
|
||||||
require 'redmine_helpdesk/patches/issues_controller_patch'
|
require 'redmine_helpdesk/patches/issues_controller_patch'
|
||||||
require 'redmine_helpdesk/patches/journals_controller_patch'
|
require 'redmine_helpdesk/patches/journals_controller_patch'
|
||||||
require 'redmine_helpdesk/patches/attachments_controller_patch'
|
require 'redmine_helpdesk/patches/attachments_controller_patch'
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
require 'time'
|
||||||
|
|
||||||
|
module RedmineHelpdesk
|
||||||
|
module IssueApiSerializer
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def serialize(issue)
|
||||||
|
ticket = safe_send(issue, :helpdesk_ticket)
|
||||||
|
return nil unless ticket
|
||||||
|
|
||||||
|
contact = safe_send(ticket, :customer)
|
||||||
|
contact_id = safe_send(ticket, :contact_id) || safe_send(contact, :id)
|
||||||
|
contact_email = primary_email(contact)
|
||||||
|
|
||||||
|
{
|
||||||
|
:id => safe_send(ticket, :id),
|
||||||
|
:contact_id => contact_id,
|
||||||
|
:message_id => safe_send(ticket, :message_id),
|
||||||
|
:source => safe_send(ticket, :source),
|
||||||
|
:is_incoming => boolean_send(ticket, :is_incoming?),
|
||||||
|
:from_address => safe_send(ticket, :from_address),
|
||||||
|
:to_address => safe_send(ticket, :to_address),
|
||||||
|
:cc_address => safe_send(ticket, :cc_address),
|
||||||
|
:ticket_date => iso8601(safe_send(ticket, :ticket_date)),
|
||||||
|
:contact => contact_payload(contact, contact_id, contact_email)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def contact_payload(contact, contact_id, contact_email)
|
||||||
|
return nil unless contact || contact_id || contact_email
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
:id => contact_id,
|
||||||
|
:name => safe_send(contact, :name),
|
||||||
|
:company => safe_send(contact, :company),
|
||||||
|
:email => contact_email
|
||||||
|
}
|
||||||
|
reject_blank_values(payload)
|
||||||
|
end
|
||||||
|
|
||||||
|
def primary_email(contact)
|
||||||
|
email = safe_send(contact, :primary_email)
|
||||||
|
return email unless blank?(email)
|
||||||
|
|
||||||
|
emails = safe_send(contact, :emails)
|
||||||
|
emails.respond_to?(:first) ? emails.first : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def iso8601(value)
|
||||||
|
return nil if blank?(value)
|
||||||
|
value.respond_to?(:utc) ? value.utc.iso8601 : value.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def boolean_send(object, method_name)
|
||||||
|
return nil unless object && object.respond_to?(method_name)
|
||||||
|
object.public_send(method_name) ? true : false
|
||||||
|
end
|
||||||
|
|
||||||
|
def safe_send(object, method_name)
|
||||||
|
return nil unless object && object.respond_to?(method_name)
|
||||||
|
object.public_send(method_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reject_blank_values(payload)
|
||||||
|
result = {}
|
||||||
|
payload.each do |key, value|
|
||||||
|
result[key] = value unless blank?(value)
|
||||||
|
end
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def blank?(value)
|
||||||
|
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
class CreateEventOutboxEvents < ActiveRecord::Migration
|
class CreateEventOutboxEvents < ActiveRecord::Migration
|
||||||
def change
|
def change
|
||||||
|
return if table_exists?(:event_outbox_events)
|
||||||
|
|
||||||
create_table :event_outbox_events do |t|
|
create_table :event_outbox_events do |t|
|
||||||
t.string :event_type, :null => false
|
t.string :event_type, :null => false
|
||||||
t.string :source_type, :null => false
|
t.string :source_type, :null => false
|
||||||
|
|||||||
Executable
+467
@@ -0,0 +1,467 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Post-import automation for the LAN Redmine development clone.
|
||||||
|
|
||||||
|
Run this after the production database and Redmine tree have been copied onto
|
||||||
|
the dev/test host. It reapplies this repository's tracked plugin forks, runs
|
||||||
|
plugin migrations, sanitizes Helpdesk mail settings, restarts Passenger, and
|
||||||
|
performs validation. The default mode is a dry run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import socket
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_SSH_HOST = "reddev@192.168.50.170"
|
||||||
|
DEFAULT_SSH_KEY = Path("/tmp/reddev")
|
||||||
|
DEFAULT_REMOTE_REDMINE = "/usr/share/redmine"
|
||||||
|
DEFAULT_REMOTE_OVERRIDES = "/home/reddev/redmine-dev-overrides"
|
||||||
|
DEFAULT_FILES_ROOT = "/var/lib/redmine/default/files"
|
||||||
|
DEFAULT_MAILPIT_HOST = "192.168.1.105"
|
||||||
|
DEFAULT_SEMANTIC_LIMITS = "customer-service=5"
|
||||||
|
DEFAULT_LANSCRATCH_ROOT = Path("/opt/lanscratch/redmine-post-import")
|
||||||
|
DEFAULT_STATUS_DIR = DEFAULT_LANSCRATCH_ROOT / "status"
|
||||||
|
DEFAULT_LOCAL_REPO_ROOT = DEFAULT_LANSCRATCH_ROOT / "repo"
|
||||||
|
|
||||||
|
TRACKED_PLUGINS = (
|
||||||
|
"redmine_event_outbox",
|
||||||
|
"redmine_contacts",
|
||||||
|
"redmine_contacts_helpdesk",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationConfig:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
apply=False,
|
||||||
|
local=False,
|
||||||
|
repo_root=None,
|
||||||
|
status_dir=DEFAULT_STATUS_DIR,
|
||||||
|
ssh_host=DEFAULT_SSH_HOST,
|
||||||
|
ssh_key=DEFAULT_SSH_KEY,
|
||||||
|
remote_redmine=DEFAULT_REMOTE_REDMINE,
|
||||||
|
remote_overrides=DEFAULT_REMOTE_OVERRIDES,
|
||||||
|
files_root=DEFAULT_FILES_ROOT,
|
||||||
|
mailpit_host=DEFAULT_MAILPIT_HOST,
|
||||||
|
semantic_limits=DEFAULT_SEMANTIC_LIMITS,
|
||||||
|
remote_sudo=True,
|
||||||
|
skip_semantic_check=False,
|
||||||
|
skip_helpdesk_reset=False,
|
||||||
|
):
|
||||||
|
self.apply = apply
|
||||||
|
self.local = local
|
||||||
|
self.repo_root = repo_root if repo_root is not None else (DEFAULT_LOCAL_REPO_ROOT if local else Path("."))
|
||||||
|
self.status_dir = status_dir
|
||||||
|
self.ssh_host = ssh_host
|
||||||
|
self.ssh_key = ssh_key
|
||||||
|
self.remote_redmine = remote_redmine
|
||||||
|
self.remote_overrides = remote_overrides
|
||||||
|
self.files_root = files_root
|
||||||
|
self.mailpit_host = mailpit_host
|
||||||
|
self.semantic_limits = semantic_limits
|
||||||
|
self.remote_sudo = remote_sudo
|
||||||
|
self.skip_semantic_check = skip_semantic_check
|
||||||
|
self.skip_helpdesk_reset = skip_helpdesk_reset
|
||||||
|
|
||||||
|
|
||||||
|
class Step:
|
||||||
|
def __init__(self, name, commands):
|
||||||
|
self.name = name
|
||||||
|
self.commands = commands
|
||||||
|
|
||||||
|
|
||||||
|
class StepResult:
|
||||||
|
def __init__(self, step, command, returncode):
|
||||||
|
self.step = step
|
||||||
|
self.command = command
|
||||||
|
self.returncode = returncode
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
config = AutomationConfig(
|
||||||
|
apply=args.apply,
|
||||||
|
local=args.local,
|
||||||
|
repo_root=args.repo_root or (DEFAULT_LOCAL_REPO_ROOT if args.local else Path(".")),
|
||||||
|
status_dir=args.status_dir,
|
||||||
|
ssh_host=args.ssh_host,
|
||||||
|
ssh_key=args.ssh_key,
|
||||||
|
remote_redmine=args.remote_redmine,
|
||||||
|
remote_overrides=args.remote_overrides,
|
||||||
|
files_root=args.files_root,
|
||||||
|
mailpit_host=args.mailpit_host,
|
||||||
|
semantic_limits=args.semantic_limits,
|
||||||
|
remote_sudo=not args.no_remote_sudo,
|
||||||
|
skip_semantic_check=args.skip_semantic_check,
|
||||||
|
skip_helpdesk_reset=args.skip_helpdesk_reset,
|
||||||
|
)
|
||||||
|
steps = build_steps(config)
|
||||||
|
run_id = utc_stamp()
|
||||||
|
results = [] # type: List[StepResult]
|
||||||
|
|
||||||
|
print(f"mode={'apply' if config.apply else 'dry-run'}")
|
||||||
|
print(f"execution={'local' if config.local else 'remote'}")
|
||||||
|
if config.apply:
|
||||||
|
write_status(config, run_id, "running", results)
|
||||||
|
for step in steps:
|
||||||
|
print(f"\n== {step.name} ==")
|
||||||
|
for command in step.commands:
|
||||||
|
if config.apply:
|
||||||
|
print(f"running: {command}")
|
||||||
|
result = subprocess.run(command, shell=True, check=False)
|
||||||
|
results.append(StepResult(step.name, command, result.returncode))
|
||||||
|
write_status(config, run_id, "running", results)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"error: command failed with exit {result.returncode}", file=sys.stderr)
|
||||||
|
write_status(config, run_id, "failed", results, failed_step=step.name)
|
||||||
|
return result.returncode
|
||||||
|
else:
|
||||||
|
print(f"would run: {command}")
|
||||||
|
|
||||||
|
if not config.apply:
|
||||||
|
print("\nDry run only. Re-run with --apply after reviewing the command list.")
|
||||||
|
else:
|
||||||
|
write_status(config, run_id, "success", results)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Run daily post-import steps for the LAN Redmine dev clone."
|
||||||
|
)
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Run commands. Default is dry-run.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--local",
|
||||||
|
action="store_true",
|
||||||
|
help="Run directly on the Redmine test host instead of orchestrating over SSH.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--repo-root",
|
||||||
|
type=Path,
|
||||||
|
default=Path(os.environ["POST_IMPORT_REPO_ROOT"]) if "POST_IMPORT_REPO_ROOT" in os.environ else None,
|
||||||
|
help="Repository/payload root to copy plugins and run helper scripts from.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--status-dir",
|
||||||
|
type=Path,
|
||||||
|
default=Path(os.getenv("POST_IMPORT_STATUS_DIR", str(DEFAULT_STATUS_DIR))),
|
||||||
|
)
|
||||||
|
parser.add_argument("--ssh-host", default=os.getenv("REDMINE_SSH_HOST", DEFAULT_SSH_HOST))
|
||||||
|
parser.add_argument(
|
||||||
|
"--ssh-key",
|
||||||
|
type=Path,
|
||||||
|
default=Path(os.getenv("REDMINE_SSH_KEY", str(DEFAULT_SSH_KEY))),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--remote-redmine",
|
||||||
|
default=os.getenv("REDMINE_REMOTE_PATH", DEFAULT_REMOTE_REDMINE),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--remote-overrides",
|
||||||
|
default=os.getenv("REDMINE_REMOTE_OVERRIDES", DEFAULT_REMOTE_OVERRIDES),
|
||||||
|
help=(
|
||||||
|
"Remote directory containing dev-only files to overlay after the production copy. "
|
||||||
|
"If it is absent on the remote host, the step is skipped."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument("--files-root", default=DEFAULT_FILES_ROOT)
|
||||||
|
parser.add_argument("--mailpit-host", default=DEFAULT_MAILPIT_HOST)
|
||||||
|
parser.add_argument("--semantic-limits", default=DEFAULT_SEMANTIC_LIMITS)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-remote-sudo",
|
||||||
|
action="store_true",
|
||||||
|
help="Do not use sudo for remote write/permission steps.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--skip-semantic-check", action="store_true")
|
||||||
|
parser.add_argument("--skip-helpdesk-reset", action="store_true")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def build_steps(config):
|
||||||
|
steps = [
|
||||||
|
Step(
|
||||||
|
"preflight",
|
||||||
|
preflight_commands(config),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
"restore dev-local overrides",
|
||||||
|
(
|
||||||
|
restore_overrides_command(config),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
"reapply tracked plugins",
|
||||||
|
(
|
||||||
|
copy_plugins_command(config),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
"run plugin migrations",
|
||||||
|
(
|
||||||
|
redmine_command(
|
||||||
|
config,
|
||||||
|
"RAILS_ENV=production bundle exec rake redmine:plugins:migrate",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
"fix dev writable paths",
|
||||||
|
(
|
||||||
|
fix_permissions_command(config),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
if not config.skip_helpdesk_reset:
|
||||||
|
steps.append(
|
||||||
|
Step(
|
||||||
|
"reset Helpdesk mail settings",
|
||||||
|
(
|
||||||
|
helpdesk_reset_command(config),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
steps.extend(
|
||||||
|
[
|
||||||
|
Step(
|
||||||
|
"restart Passenger",
|
||||||
|
(
|
||||||
|
redmine_command(config, "touch tmp/restart.txt"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
"validate test instance",
|
||||||
|
(
|
||||||
|
validate_command(config),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Step(
|
||||||
|
"check outbox worker",
|
||||||
|
outbox_commands(config),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if not config.skip_semantic_check:
|
||||||
|
steps.append(
|
||||||
|
Step(
|
||||||
|
"validate semantic index dry-run",
|
||||||
|
(
|
||||||
|
semantic_check_command(config),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return steps
|
||||||
|
|
||||||
|
|
||||||
|
def preflight_commands(config):
|
||||||
|
commands = tuple(local_test(str(config.repo_root / "plugins" / plugin)) for plugin in TRACKED_PLUGINS)
|
||||||
|
if config.local:
|
||||||
|
return commands + (f"test -d {q(config.remote_redmine)}",)
|
||||||
|
return commands + (ssh(config, f"test -d {q(config.remote_redmine)}"),)
|
||||||
|
|
||||||
|
|
||||||
|
def semantic_check_command(config):
|
||||||
|
refresh = config.repo_root / "semantic_index" / "refresh.sh"
|
||||||
|
python_bin = config.repo_root / ".venv" / "bin" / "python"
|
||||||
|
command = "SEMANTIC_INDEX_PROJECT_LIMITS={limits} {refresh}".format(
|
||||||
|
limits=q(config.semantic_limits),
|
||||||
|
refresh=q(str(refresh)),
|
||||||
|
)
|
||||||
|
if not config.local:
|
||||||
|
return command
|
||||||
|
return (
|
||||||
|
"if test -x {python}; then PYTHON={python} {command}; "
|
||||||
|
"else echo 'semantic index runtime missing; skipping dry-run'; fi"
|
||||||
|
).format(
|
||||||
|
python=q(str(python_bin)),
|
||||||
|
command=command,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def local_test(path):
|
||||||
|
return f"test -d {q(path)}"
|
||||||
|
|
||||||
|
|
||||||
|
def restore_overrides_command(config):
|
||||||
|
command = "if [ -d {overrides} ]; then {sudo}rsync -a {overrides}/ {redmine}/; else echo 'no dev overrides directory: {overrides}'; fi".format(
|
||||||
|
sudo=remote_sudo_prefix(config),
|
||||||
|
overrides=q(config.remote_overrides),
|
||||||
|
redmine=q(config.remote_redmine),
|
||||||
|
)
|
||||||
|
return command if config.local else ssh(config, command)
|
||||||
|
|
||||||
|
|
||||||
|
def copy_plugins_command(config):
|
||||||
|
sources = " ".join(q(str(config.repo_root / "plugins" / plugin)) for plugin in TRACKED_PLUGINS)
|
||||||
|
if config.local:
|
||||||
|
return f"rsync -a --delete {sources} {q(config.remote_redmine + '/plugins/')}"
|
||||||
|
return (
|
||||||
|
"rsync -a --delete "
|
||||||
|
f"{rsync_path_option(config)}"
|
||||||
|
f"-e {q(ssh_transport(config))} "
|
||||||
|
f"{sources} "
|
||||||
|
f"{q(config.ssh_host + ':' + config.remote_redmine + '/plugins/')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def redmine_command(config, command):
|
||||||
|
full = f"cd {q(config.remote_redmine)} && {command}"
|
||||||
|
return full if config.local else ssh(config, full)
|
||||||
|
|
||||||
|
|
||||||
|
def fix_permissions_command(config):
|
||||||
|
command = "{sudo}mkdir -p {files} {redmine}/tmp {redmine}/log && {sudo}chmod -R g+rwX {files} {redmine}/tmp {redmine}/log && {sudo}find {files} -type d -exec chmod g+s {{}} +".format(
|
||||||
|
sudo=remote_sudo_prefix(config),
|
||||||
|
files=q(config.files_root),
|
||||||
|
redmine=q(config.remote_redmine),
|
||||||
|
)
|
||||||
|
return command if config.local else ssh(config, command)
|
||||||
|
|
||||||
|
|
||||||
|
def script(config, name):
|
||||||
|
path = config.repo_root / name
|
||||||
|
if str(config.repo_root) == ".":
|
||||||
|
return q(f"./{name}")
|
||||||
|
return q(str(path))
|
||||||
|
|
||||||
|
|
||||||
|
def helpdesk_reset_command(config):
|
||||||
|
base = f"{script(config, 'reset_helpdesk_mail_settings.py')} "
|
||||||
|
if config.local:
|
||||||
|
return (
|
||||||
|
base +
|
||||||
|
f"--local --remote-redmine {q(config.remote_redmine)} "
|
||||||
|
f"--mailpit-host {q(config.mailpit_host)}"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
base +
|
||||||
|
f"--ssh-host {q(config.ssh_host)} "
|
||||||
|
f"--ssh-key {q(str(config.ssh_key))} "
|
||||||
|
f"--remote-redmine {q(config.remote_redmine)} "
|
||||||
|
f"--mailpit-host {q(config.mailpit_host)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_command(config):
|
||||||
|
base = f"{script(config, 'validate_test_instance.py')} "
|
||||||
|
if config.local:
|
||||||
|
return (
|
||||||
|
base +
|
||||||
|
f"--local --remote-redmine {q(config.remote_redmine)} "
|
||||||
|
f"--mailpit-host {q(config.mailpit_host)} "
|
||||||
|
f"--files-root {q(config.files_root)}"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
base +
|
||||||
|
f"--ssh-host {q(config.ssh_host)} "
|
||||||
|
f"--ssh-key {q(str(config.ssh_key))} "
|
||||||
|
f"--remote-redmine {q(config.remote_redmine)} "
|
||||||
|
f"--mailpit-host {q(config.mailpit_host)} "
|
||||||
|
f"--files-root {q(config.files_root)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def outbox_commands(config):
|
||||||
|
base = script(config, "redmine_outbox_worker.py")
|
||||||
|
local = " --local" if config.local else ""
|
||||||
|
return (
|
||||||
|
f"{base}{local} --status",
|
||||||
|
f"{base}{local} --dry-run --batch-size 10",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ssh(config, remote_command):
|
||||||
|
return (
|
||||||
|
"ssh "
|
||||||
|
f"-i {q(str(config.ssh_key))} "
|
||||||
|
"-o IdentitiesOnly=yes "
|
||||||
|
f"{q(config.ssh_host)} "
|
||||||
|
f"{q(remote_command)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_transport(config):
|
||||||
|
return f"ssh -i {str(config.ssh_key)} -o IdentitiesOnly=yes"
|
||||||
|
|
||||||
|
|
||||||
|
def remote_sudo_prefix(config):
|
||||||
|
return "sudo " if config.remote_sudo else ""
|
||||||
|
|
||||||
|
|
||||||
|
def rsync_path_option(config):
|
||||||
|
return "--rsync-path 'sudo rsync' " if config.remote_sudo else ""
|
||||||
|
|
||||||
|
|
||||||
|
def write_status(
|
||||||
|
config: AutomationConfig,
|
||||||
|
run_id: str,
|
||||||
|
status: str,
|
||||||
|
results,
|
||||||
|
failed_step=None,
|
||||||
|
):
|
||||||
|
now = utc_iso()
|
||||||
|
document = {
|
||||||
|
"run_id": run_id,
|
||||||
|
"started_at": run_id_to_iso(run_id),
|
||||||
|
"updated_at": now,
|
||||||
|
"finished_at": now if status in {"success", "failed"} else None,
|
||||||
|
"mode": "apply" if config.apply else "dry-run",
|
||||||
|
"execution": "local" if config.local else "remote",
|
||||||
|
"host": socket.gethostname(),
|
||||||
|
"remote_redmine": config.remote_redmine,
|
||||||
|
"repo_root": str(config.repo_root),
|
||||||
|
"status": status,
|
||||||
|
"failed_step": failed_step,
|
||||||
|
"steps": [
|
||||||
|
{"step": item.step, "command": item.command, "returncode": item.returncode}
|
||||||
|
for item in results
|
||||||
|
],
|
||||||
|
}
|
||||||
|
config.status_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
runs_dir = config.status_dir / "runs"
|
||||||
|
runs_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
write_json(runs_dir / f"{run_id}.json", document)
|
||||||
|
write_json(config.status_dir / "latest.json", document)
|
||||||
|
if status == "success":
|
||||||
|
write_json(config.status_dir / "latest-success.json", document)
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path, document):
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
tmp.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def utc_stamp():
|
||||||
|
return dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def utc_iso():
|
||||||
|
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def run_id_to_iso(run_id):
|
||||||
|
try:
|
||||||
|
parsed = dt.datetime.strptime(run_id, "%Y%m%dT%H%M%SZ").replace(tzinfo=dt.timezone.utc)
|
||||||
|
return parsed.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
except ValueError:
|
||||||
|
return run_id
|
||||||
|
|
||||||
|
|
||||||
|
def q(value):
|
||||||
|
return shlex.quote(value)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+222
-7
@@ -45,7 +45,6 @@ $created = $client->createIssue([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$client->updateIssue((int) $created['id'], ['notes' => 'Follow-up note']);
|
$client->updateIssue((int) $created['id'], ['notes' => 'Follow-up note']);
|
||||||
$client->deleteIssue((int) $created['id']);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Native Redmine search is exposed separately from issue filtering. Use
|
Native Redmine search is exposed separately from issue filtering. Use
|
||||||
@@ -65,6 +64,69 @@ $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.
|
||||||
|
|
||||||
|
```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 +146,159 @@ 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:
|
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`.
|
||||||
|
|
||||||
|
Example stdio client configuration:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -105,12 +310,22 @@ Example client configuration:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The server exposes tools for native Redmine filtering/search, issue CRUD,
|
Both transports expose tools for native Redmine project listing/detail, project
|
||||||
Helpdesk-aware issue reads, and explicit Helpdesk email responses. Tools that
|
memberships, users, filtering/search, issue create/update, issue relations,
|
||||||
can send customer-visible mail require an explicit tool call such as
|
subtasks/parents, project issue categories, attachments, Helpdesk-aware issue
|
||||||
|
reads, and explicit Helpdesk email responses. Tools that can send
|
||||||
|
customer-visible mail require an explicit tool call such as
|
||||||
`redmine_send_helpdesk_response` or `redmine_update_issue` with
|
`redmine_send_helpdesk_response` or `redmine_update_issue` with
|
||||||
`send_helpdesk_email=true`.
|
`send_helpdesk_email=true`.
|
||||||
|
|
||||||
|
Run the local no-network query normalizer checks with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
php redMCP/bin/test-query-normalizer.php
|
||||||
|
php redMCP/bin/test-redmine-structure.php
|
||||||
|
php redMCP/bin/test-mcp-http-handler.php
|
||||||
|
```
|
||||||
|
|
||||||
## Test instance
|
## Test instance
|
||||||
|
|
||||||
A working test copy of Redmine is available on the LAN at `192.168.50.170`.
|
A working test copy of Redmine is available on the LAN at `192.168.50.170`.
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace RedMCP;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use DateTimeZone;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class ListQueryNormalizer
|
||||||
|
{
|
||||||
|
private const DEFAULT_LIMIT = 25;
|
||||||
|
private const MAX_LIMIT = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public static function listParams(array $arguments): array
|
||||||
|
{
|
||||||
|
$params = self::pagingParams($arguments);
|
||||||
|
self::addSort($params, $arguments['sort'] ?? null);
|
||||||
|
|
||||||
|
return array_merge($params, self::objectValue($arguments, 'params'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public static function issueFilters(array $arguments, ?DateTimeImmutable $clock = null): array
|
||||||
|
{
|
||||||
|
$filters = self::pagingParams($arguments);
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'project_id',
|
||||||
|
'tracker_id',
|
||||||
|
'assigned_to_id',
|
||||||
|
'author_id',
|
||||||
|
'priority_id',
|
||||||
|
'category_id',
|
||||||
|
'query_id',
|
||||||
|
] as $key) {
|
||||||
|
if (array_key_exists($key, $arguments)) {
|
||||||
|
$filters[$key] = $arguments[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('status', $arguments)) {
|
||||||
|
$filters['status_id'] = self::statusValue($arguments['status']);
|
||||||
|
} elseif (array_key_exists('status_id', $arguments)) {
|
||||||
|
$filters['status_id'] = self::statusValue($arguments['status_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::addDateFilter($filters, 'created_on', $arguments['created'] ?? null, $clock);
|
||||||
|
self::addDateFilter($filters, 'updated_on', $arguments['updated'] ?? null, $clock);
|
||||||
|
self::addDateFilter($filters, 'due_date', $arguments['due'] ?? null, $clock);
|
||||||
|
self::addSort($filters, $arguments['sort'] ?? null);
|
||||||
|
|
||||||
|
return array_merge($filters, self::objectValue($arguments, 'filters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public static function userParams(array $arguments): array
|
||||||
|
{
|
||||||
|
$params = self::pagingParams($arguments);
|
||||||
|
|
||||||
|
foreach (['name', 'group_id'] as $key) {
|
||||||
|
if (array_key_exists($key, $arguments)) {
|
||||||
|
$params[$key] = $arguments[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('status', $arguments)) {
|
||||||
|
$params['status'] = self::userStatusValue($arguments['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::addSort($params, $arguments['sort'] ?? null);
|
||||||
|
|
||||||
|
return array_merge($params, self::objectValue($arguments, 'params'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public static function searchParams(array $arguments): array
|
||||||
|
{
|
||||||
|
$params = self::pagingParams($arguments);
|
||||||
|
|
||||||
|
foreach (['project_id', 'scope'] as $key) {
|
||||||
|
if (array_key_exists($key, $arguments)) {
|
||||||
|
$params[$key] = $arguments[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['all_words', 'titles_only', 'open_issues'] as $key) {
|
||||||
|
if (array_key_exists($key, $arguments)) {
|
||||||
|
$params[$key] = self::booleanString($arguments[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::addSort($params, $arguments['sort'] ?? null);
|
||||||
|
|
||||||
|
return array_merge($params, self::objectValue($arguments, 'params'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private static function pagingParams(array $arguments): array
|
||||||
|
{
|
||||||
|
$limit = self::positiveInt($arguments['limit'] ?? self::DEFAULT_LIMIT, self::DEFAULT_LIMIT);
|
||||||
|
$limit = min($limit, self::MAX_LIMIT);
|
||||||
|
|
||||||
|
$params = ['limit' => $limit];
|
||||||
|
if (array_key_exists('offset', $arguments)) {
|
||||||
|
$params['offset'] = max(0, (int) $arguments['offset']);
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('page', $arguments)) {
|
||||||
|
$page = max(1, (int) $arguments['page']);
|
||||||
|
$params['offset'] = ($page - 1) * $limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $params;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function positiveInt(mixed $value, int $default): int
|
||||||
|
{
|
||||||
|
$value = (int) $value;
|
||||||
|
|
||||||
|
return $value > 0 ? $value : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $params
|
||||||
|
* @param mixed $sort
|
||||||
|
*/
|
||||||
|
private static function addSort(array &$params, $sort): void
|
||||||
|
{
|
||||||
|
$normalized = self::sortValue($sort);
|
||||||
|
if ($normalized !== null) {
|
||||||
|
$params['sort'] = $normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $sort
|
||||||
|
*/
|
||||||
|
private static function sortValue($sort): ?string
|
||||||
|
{
|
||||||
|
if ($sort === null || $sort === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_array($sort)) {
|
||||||
|
$parts = [];
|
||||||
|
foreach ($sort as $item) {
|
||||||
|
if (!is_array($item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$field = trim((string) ($item['field'] ?? ''));
|
||||||
|
if ($field === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$direction = strtolower((string) ($item['direction'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';
|
||||||
|
$parts[] = $field . ':' . $direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parts === [] ? null : implode(',', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sort = trim((string) $sort);
|
||||||
|
$shortcut = strtolower(str_replace([' ', '-'], '_', $sort));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'newest' => 'updated_on:desc',
|
||||||
|
'recent' => 'updated_on:desc',
|
||||||
|
'oldest' => 'created_on:asc',
|
||||||
|
'created_newest' => 'created_on:desc',
|
||||||
|
'created_oldest' => 'created_on:asc',
|
||||||
|
'priority' => 'priority:desc,updated_on:desc',
|
||||||
|
][$shortcut] ?? $sort;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
private static function statusValue($value): mixed
|
||||||
|
{
|
||||||
|
$status = strtolower(trim((string) $value));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'open' => 'open',
|
||||||
|
'opened' => 'open',
|
||||||
|
'active' => 'open',
|
||||||
|
'closed' => 'closed',
|
||||||
|
'all' => '*',
|
||||||
|
'any' => '*',
|
||||||
|
][$status] ?? $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
private static function userStatusValue($value): mixed
|
||||||
|
{
|
||||||
|
$status = strtolower(trim((string) $value));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'active' => 1,
|
||||||
|
'registered' => 2,
|
||||||
|
'locked' => 3,
|
||||||
|
'all' => '*',
|
||||||
|
'any' => '*',
|
||||||
|
][$status] ?? $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $params
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
private static function addDateFilter(array &$params, string $redmineField, $value, ?DateTimeImmutable $clock): void
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$params[$redmineField] = self::dateValue($value, $clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
private static function dateValue($value, ?DateTimeImmutable $clock): string
|
||||||
|
{
|
||||||
|
$clock = $clock ?? new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
||||||
|
$clock = $clock->setTimezone(new DateTimeZone('UTC'));
|
||||||
|
|
||||||
|
if (is_array($value)) {
|
||||||
|
$from = self::datePart($value['from'] ?? null, $clock);
|
||||||
|
$to = self::datePart($value['to'] ?? null, $clock);
|
||||||
|
|
||||||
|
return self::rangeValue($from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = trim((string) $value);
|
||||||
|
if ($text === '') {
|
||||||
|
throw new RuntimeException('Date filter cannot be empty.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^(.+)\.\.(.+)$/', $text, $matches)) {
|
||||||
|
return self::rangeValue(self::datePart($matches[1], $clock), self::datePart($matches[2], $clock));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^(since|after)\s+(.+)$/i', $text, $matches)) {
|
||||||
|
return '>=' . self::datePart($matches[2], $clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^(before|until)\s+(.+)$/i', $text, $matches)) {
|
||||||
|
return '<=' . self::datePart($matches[2], $clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
$preset = strtolower(str_replace(['-', ' '], '_', $text));
|
||||||
|
if ($preset === 'today') {
|
||||||
|
return $clock->format('Y-m-d');
|
||||||
|
}
|
||||||
|
if ($preset === 'yesterday') {
|
||||||
|
return $clock->modify('-1 day')->format('Y-m-d');
|
||||||
|
}
|
||||||
|
if (preg_match('/^last_(\d+)_days$/', $preset, $matches)) {
|
||||||
|
$days = max(1, (int) $matches[1]);
|
||||||
|
return self::rangeValue($clock->modify('-' . ($days - 1) . ' days')->format('Y-m-d'), $clock->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
if ($preset === 'this_week') {
|
||||||
|
return self::rangeValue($clock->modify('monday this week')->format('Y-m-d'), $clock->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
if ($preset === 'last_week') {
|
||||||
|
return self::rangeValue(
|
||||||
|
$clock->modify('monday last week')->format('Y-m-d'),
|
||||||
|
$clock->modify('sunday last week')->format('Y-m-d')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if ($preset === 'this_month') {
|
||||||
|
return self::rangeValue($clock->modify('first day of this month')->format('Y-m-d'), $clock->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
if ($preset === 'last_month') {
|
||||||
|
return self::rangeValue(
|
||||||
|
$clock->modify('first day of last month')->format('Y-m-d'),
|
||||||
|
$clock->modify('last day of last month')->format('Y-m-d')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::datePart($text, $clock);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function rangeValue(?string $from, ?string $to): string
|
||||||
|
{
|
||||||
|
if ($from !== null && $to !== null) {
|
||||||
|
return '><' . $from . '|' . $to;
|
||||||
|
}
|
||||||
|
if ($from !== null) {
|
||||||
|
return '>=' . $from;
|
||||||
|
}
|
||||||
|
if ($to !== null) {
|
||||||
|
return '<=' . $to;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException('Date range requires from, to, or both.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
private static function datePart($value, DateTimeImmutable $clock): ?string
|
||||||
|
{
|
||||||
|
if ($value === null || trim((string) $value) === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$text = trim((string) $value);
|
||||||
|
$timestamp = strtotime($text, $clock->getTimestamp());
|
||||||
|
if ($timestamp === false) {
|
||||||
|
throw new RuntimeException('Could not parse date filter: ' . $text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return gmdate('Y-m-d', $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $value
|
||||||
|
*/
|
||||||
|
private static function booleanString($value): string
|
||||||
|
{
|
||||||
|
if (is_string($value)) {
|
||||||
|
$normalized = strtolower(trim($value));
|
||||||
|
if (in_array($normalized, ['1', 'true', 'yes', 'on'], true)) {
|
||||||
|
return '1';
|
||||||
|
}
|
||||||
|
if (in_array($normalized, ['0', 'false', 'no', 'off'], true)) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value ? '1' : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $arguments
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private static function objectValue(array $arguments, string $key): array
|
||||||
|
{
|
||||||
|
return is_array($arguments[$key] ?? null) ? $arguments[$key] : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace RedMCP;
|
||||||
|
|
||||||
|
final class McpDebugLogger
|
||||||
|
{
|
||||||
|
private ?string $path;
|
||||||
|
|
||||||
|
public function __construct(?string $path)
|
||||||
|
{
|
||||||
|
$this->path = $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enabled(): bool
|
||||||
|
{
|
||||||
|
return $this->path !== null && $this->path !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $record
|
||||||
|
*/
|
||||||
|
public function log(array $record): void
|
||||||
|
{
|
||||||
|
if (!$this->enabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$record = ['timestamp' => gmdate('c')] + $record;
|
||||||
|
$encoded = json_encode($record, JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($encoded === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dir = dirname((string) $this->path);
|
||||||
|
if ($dir !== '' && $dir !== '.' && !is_dir($dir)) {
|
||||||
|
mkdir($dir, 0775, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
file_put_contents((string) $this->path, $encoded . "\n", FILE_APPEND | LOCK_EX);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,761 @@
|
|||||||
|
<?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;
|
||||||
|
|
||||||
|
public function __construct(RedmineClient $redmine, ?McpDebugLogger $logger = null)
|
||||||
|
{
|
||||||
|
$this->redmine = $redmine;
|
||||||
|
$this->logger = $logger ?? new McpDebugLogger(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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'],
|
||||||
|
'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'],
|
||||||
|
'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->projectIdArg($arguments, 'project_id'), $this->objectArg($arguments, 'params'));
|
||||||
|
break;
|
||||||
|
case 'redmine_list_project_memberships':
|
||||||
|
$result = $this->redmine->projectMemberships($this->projectIdArg($arguments, 'project_id'), 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($arguments));
|
||||||
|
break;
|
||||||
|
case 'redmine_search':
|
||||||
|
$result = $this->redmine->search($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($arguments));
|
||||||
|
break;
|
||||||
|
case 'redmine_search_issues':
|
||||||
|
$result = $this->redmine->searchIssues($this->stringArg($arguments, 'query'), ListQueryNormalizer::searchParams($arguments));
|
||||||
|
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));
|
||||||
|
break;
|
||||||
|
case 'redmine_update_issue':
|
||||||
|
$result = ['ok' => $this->redmine->updateIssue($this->intArg($arguments, 'issue_id'), $this->issueFieldsArg($arguments), $this->objectArg($arguments, 'options'))];
|
||||||
|
break;
|
||||||
|
case 'redmine_list_project_issue_categories':
|
||||||
|
$result = $this->redmine->listProjectIssueCategories($this->projectIdArg($arguments, 'project_id'));
|
||||||
|
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->projectIdArg($arguments, 'project_id'), $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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoded = json_encode($this->redactSensitive($result), 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): array
|
||||||
|
{
|
||||||
|
$fields = $this->objectArg($arguments, 'fields');
|
||||||
|
foreach (self::ISSUE_FIELD_ARGUMENT_KEYS as $key) {
|
||||||
|
if (array_key_exists($key, $arguments)) {
|
||||||
|
$fields[$key] = $arguments[$key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?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}
|
||||||
|
*/
|
||||||
|
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)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace RedMCP;
|
||||||
|
|
||||||
|
final class McpHttpHandler
|
||||||
|
{
|
||||||
|
private McpDispatcher $dispatcher;
|
||||||
|
private string $token;
|
||||||
|
private string $path;
|
||||||
|
|
||||||
|
public function __construct(McpDispatcher $dispatcher, string $token, string $path = '/mcp')
|
||||||
|
{
|
||||||
|
$this->dispatcher = $dispatcher;
|
||||||
|
$this->token = $token;
|
||||||
|
$this->path = '/' . trim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
if (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) !== $this->path) {
|
||||||
|
$this->sendJson(404, ['error' => 'not found']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!$this->originAllowed()) {
|
||||||
|
$this->sendJson(403, ['error' => 'origin not allowed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!$this->authorized()) {
|
||||||
|
header('WWW-Authenticate: Bearer');
|
||||||
|
$this->sendJson(401, ['error' => 'unauthorized']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'GET') {
|
||||||
|
header('Allow: POST');
|
||||||
|
$this->sendJson(405, ['error' => 'method not allowed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
|
||||||
|
header('Allow: POST');
|
||||||
|
$this->sendJson(405, ['error' => 'method not allowed']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = file_get_contents('php://input');
|
||||||
|
$decoded = json_decode(is_string($raw) ? $raw : '', true);
|
||||||
|
if (!is_array($decoded)) {
|
||||||
|
$this->sendJson(400, $this->errorResponse(null, -32700, 'Invalid JSON.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_is_list($decoded)) {
|
||||||
|
$responses = [];
|
||||||
|
foreach ($decoded as $message) {
|
||||||
|
if (!is_array($message)) {
|
||||||
|
$responses[] = $this->errorResponse(null, -32600, 'Invalid request.');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$response = $this->dispatcher->handleMessage($message, $this->logContext());
|
||||||
|
if ($response !== null) {
|
||||||
|
$responses[] = $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($responses === []) {
|
||||||
|
http_response_code(202);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($this->acceptsEventStream()) {
|
||||||
|
$this->sendEventStream($responses);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->sendJson(200, $responses);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->dispatcher->handleMessage($decoded, $this->logContext());
|
||||||
|
if ($response === null) {
|
||||||
|
http_response_code(202);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->acceptsEventStream()) {
|
||||||
|
$this->sendEventStream([$response]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->sendJson(200, $response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authorized(): bool
|
||||||
|
{
|
||||||
|
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
|
||||||
|
if (!is_string($header) || !str_starts_with($header, 'Bearer ')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash_equals($this->token, substr($header, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function originAllowed(): bool
|
||||||
|
{
|
||||||
|
$origin = $_SERVER['HTTP_ORIGIN'] ?? '';
|
||||||
|
if (!is_string($origin) || trim($origin) === '') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$origin = trim($origin);
|
||||||
|
foreach ($this->allowedOrigins() as $allowedOrigin) {
|
||||||
|
if (hash_equals($allowedOrigin, $origin)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = parse_url($origin, PHP_URL_HOST);
|
||||||
|
return is_string($host) && in_array(strtolower($host), ['localhost', '127.0.0.1', '::1'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
private function allowedOrigins(): array
|
||||||
|
{
|
||||||
|
$raw = getenv('MCP_ALLOWED_ORIGINS') ?: '';
|
||||||
|
if (!is_string($raw) || trim($raw) === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_filter(array_map('trim', explode(',', $raw))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function acceptsEventStream(): bool
|
||||||
|
{
|
||||||
|
$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
|
||||||
|
return is_string($accept) && stripos($accept, 'text/event-stream') !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function logContext(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'transport' => 'http',
|
||||||
|
'client_ip' => $_SERVER['REMOTE_ADDR'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $payload
|
||||||
|
*/
|
||||||
|
private function sendJson(int $status, $payload): void
|
||||||
|
{
|
||||||
|
http_response_code($status);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,array<string,mixed>> $messages
|
||||||
|
*/
|
||||||
|
private function sendEventStream(array $messages): void
|
||||||
|
{
|
||||||
|
http_response_code(200);
|
||||||
|
header('Content-Type: text/event-stream');
|
||||||
|
header('Cache-Control: no-cache');
|
||||||
|
header('X-Accel-Buffering: no');
|
||||||
|
|
||||||
|
foreach ($messages as $message) {
|
||||||
|
$encoded = json_encode($message, JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($encoded === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
echo "event: message\n";
|
||||||
|
echo 'data: ' . $encoded . "\n\n";
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function errorResponse(mixed $id, int $code, string $message): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'id' => $id,
|
||||||
|
'error' => [
|
||||||
|
'code' => $code,
|
||||||
|
'message' => $message,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace RedMCP;
|
||||||
|
|
||||||
|
final class McpStdioServer
|
||||||
|
{
|
||||||
|
private McpDispatcher $dispatcher;
|
||||||
|
|
||||||
|
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 (!str_contains($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;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, 'Content-Length: ' . strlen($body) . "\r\n\r\n" . $body);
|
||||||
|
fflush(STDOUT);
|
||||||
|
}
|
||||||
|
}
|
||||||
+567
-19
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace RedMCP;
|
namespace RedMCP;
|
||||||
|
|
||||||
|
use Redmine\Client\Client;
|
||||||
use Redmine\Client\NativeCurlClient;
|
use Redmine\Client\NativeCurlClient;
|
||||||
use Redmine\Http\HttpClient;
|
use Redmine\Http\HttpClient;
|
||||||
use Redmine\Http\HttpFactory;
|
use Redmine\Http\HttpFactory;
|
||||||
@@ -14,9 +15,9 @@ use Throwable;
|
|||||||
|
|
||||||
final class RedmineClient
|
final class RedmineClient
|
||||||
{
|
{
|
||||||
private NativeCurlClient $client;
|
private Client $client;
|
||||||
|
|
||||||
public function __construct(NativeCurlClient $client)
|
public function __construct(Client $client)
|
||||||
{
|
{
|
||||||
$this->client = $client;
|
$this->client = $client;
|
||||||
}
|
}
|
||||||
@@ -105,6 +106,114 @@ final class RedmineClient
|
|||||||
return $this->search($query, ['issues' => '1'] + $params);
|
return $this->search($query, ['issues' => '1'] + $params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Redmine projects.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params Standard Redmine project list params.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function projects(array $params = []): array
|
||||||
|
{
|
||||||
|
return $this->listProjects($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Redmine projects.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params Standard Redmine project list params.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function listProjects(array $params = []): array
|
||||||
|
{
|
||||||
|
return $this->getJson('/projects', $params) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a Redmine project by numeric id or identifier.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params Standard Redmine project show params.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function project(int|string $projectId, array $params = []): array
|
||||||
|
{
|
||||||
|
$projectId = trim((string) $projectId);
|
||||||
|
if ($projectId === '') {
|
||||||
|
throw new RuntimeException('Fetching a project requires a project id or identifier.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->getJson('/projects/' . rawurlencode($projectId), $params);
|
||||||
|
if (!is_array($response)) {
|
||||||
|
throw new RuntimeException('Could not fetch project ' . $projectId . '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response['project'] ?? $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Redmine users.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params Standard Redmine user list params.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function users(array $params = []): array
|
||||||
|
{
|
||||||
|
return $this->listUsers($params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List Redmine users.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params Standard Redmine user list params.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function listUsers(array $params = []): array
|
||||||
|
{
|
||||||
|
return $this->getJson('/users', $params) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch one Redmine user.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params Standard Redmine user show params.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function user(int $userId, array $params = []): array
|
||||||
|
{
|
||||||
|
if ($userId <= 0) {
|
||||||
|
throw new RuntimeException('Fetching a user requires a positive user id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->getJson('/users/' . rawurlencode((string) $userId), $params);
|
||||||
|
if (!is_array($response)) {
|
||||||
|
throw new RuntimeException('Could not fetch user #' . $userId . '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response['user'] ?? $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List memberships for a Redmine project.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $params Standard Redmine membership list params.
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function projectMemberships(int|string $projectId, array $params = []): array
|
||||||
|
{
|
||||||
|
$projectId = trim((string) $projectId);
|
||||||
|
if ($projectId === '') {
|
||||||
|
throw new RuntimeException('Fetching project memberships requires a project id or identifier.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getJson('/projects/' . rawurlencode($projectId) . '/memberships', $params) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch a normal Redmine issue.
|
* Fetch a normal Redmine issue.
|
||||||
*
|
*
|
||||||
@@ -128,8 +237,8 @@ final class RedmineClient
|
|||||||
* Create a Redmine issue.
|
* Create a Redmine issue.
|
||||||
*
|
*
|
||||||
* Typical fields include project_id, subject, description, tracker_id,
|
* Typical fields include project_id, subject, description, tracker_id,
|
||||||
* status_id, priority_id, assigned_to_id, category_id, due_date, and
|
* status_id, priority_id, assigned_to_id, category_id, parent_issue_id,
|
||||||
* start_date.
|
* parent_id, uploads, due_date, and start_date.
|
||||||
*
|
*
|
||||||
* @param array<string,mixed> $fields
|
* @param array<string,mixed> $fields
|
||||||
*
|
*
|
||||||
@@ -141,11 +250,9 @@ final class RedmineClient
|
|||||||
throw new RuntimeException('Creating an issue requires at least project_id and subject.');
|
throw new RuntimeException('Creating an issue requires at least project_id and subject.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$issueApi = $this->client->getApi('issue');
|
$response = $this->postJson('/issues', ['issue' => $fields]);
|
||||||
$response = $issueApi->create($fields);
|
|
||||||
$this->assertLastApiResponseSucceeded($issueApi, 'create issue');
|
|
||||||
|
|
||||||
return $this->xmlResponseToArray($response);
|
return is_array($response['issue'] ?? null) ? $response['issue'] : $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,7 +264,8 @@ final class RedmineClient
|
|||||||
* sendHelpdeskIssueResponse() directly.
|
* sendHelpdeskIssueResponse() directly.
|
||||||
*
|
*
|
||||||
* Typical fields include notes, subject, status_id, priority_id,
|
* Typical fields include notes, subject, status_id, priority_id,
|
||||||
* assigned_to_id, private_notes, due_date, and tracker_id.
|
* assigned_to_id, private_notes, parent_issue_id, parent_id, category_id,
|
||||||
|
* uploads, due_date, and tracker_id.
|
||||||
*
|
*
|
||||||
* @param array<string,mixed> $fields
|
* @param array<string,mixed> $fields
|
||||||
*/
|
*/
|
||||||
@@ -186,13 +294,383 @@ final class RedmineClient
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
$issueApi = $this->client->getApi('issue');
|
$this->putJson('/issues/' . rawurlencode((string) $issueId), ['issue' => $fields]);
|
||||||
$issueApi->update($issueId, $fields);
|
|
||||||
$this->assertLastApiResponseSucceeded($issueApi, 'update issue #' . $issueId);
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function issueWithStructure(int $issueId): array
|
||||||
|
{
|
||||||
|
return $this->issue($issueId, ['journals', 'attachments', 'children', 'relations']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function listIssueChildren(int $issueId, array $params = []): array
|
||||||
|
{
|
||||||
|
return $this->listIssues(['parent_id' => $issueId] + $params);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setIssueParent(int $issueId, int $parentIssueId): bool
|
||||||
|
{
|
||||||
|
if ($parentIssueId <= 0) {
|
||||||
|
throw new RuntimeException('Setting an issue parent requires a positive parent_issue_id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->updateIssue($issueId, ['parent_issue_id' => $parentIssueId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearIssueParent(int $issueId): bool
|
||||||
|
{
|
||||||
|
return $this->updateIssue($issueId, ['parent_issue_id' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function listIssueRelations(int $issueId): array
|
||||||
|
{
|
||||||
|
return $this->getJson('/issues/' . rawurlencode((string) $issueId) . '/relations') ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function issueRelation(int $relationId): array
|
||||||
|
{
|
||||||
|
if ($relationId <= 0) {
|
||||||
|
throw new RuntimeException('Fetching an issue relation requires a positive relation id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->getJson('/relations/' . rawurlencode((string) $relationId));
|
||||||
|
if (!is_array($response)) {
|
||||||
|
throw new RuntimeException('Could not fetch issue relation #' . $relationId . '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response['relation'] ?? $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $fields
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function createIssueRelation(int $issueId, array $fields): array
|
||||||
|
{
|
||||||
|
if (!isset($fields['issue_to_id']) || (int) $fields['issue_to_id'] <= 0) {
|
||||||
|
throw new RuntimeException('Creating an issue relation requires a positive issue_to_id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields += ['relation_type' => 'relates'];
|
||||||
|
$response = $this->postJson('/issues/' . rawurlencode((string) $issueId) . '/relations', ['relation' => $fields]);
|
||||||
|
|
||||||
|
return is_array($response['relation'] ?? null) ? $response['relation'] : $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeIssueRelation(int $relationId): bool
|
||||||
|
{
|
||||||
|
if ($relationId <= 0) {
|
||||||
|
throw new RuntimeException('Removing an issue relation requires a positive relation id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->deleteJson('/relations/' . rawurlencode((string) $relationId));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function listProjectIssueCategories(int|string $projectId): array
|
||||||
|
{
|
||||||
|
$projectId = trim((string) $projectId);
|
||||||
|
if ($projectId === '') {
|
||||||
|
throw new RuntimeException('Listing issue categories requires a project id or identifier.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->getJson('/projects/' . rawurlencode($projectId) . '/issue_categories') ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function issueCategory(int $categoryId): array
|
||||||
|
{
|
||||||
|
if ($categoryId <= 0) {
|
||||||
|
throw new RuntimeException('Fetching an issue category requires a positive category id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->getJson('/issue_categories/' . rawurlencode((string) $categoryId));
|
||||||
|
if (!is_array($response)) {
|
||||||
|
throw new RuntimeException('Could not fetch issue category #' . $categoryId . '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response['issue_category'] ?? $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $fields
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function createIssueCategory(int|string $projectId, array $fields): array
|
||||||
|
{
|
||||||
|
$projectId = trim((string) $projectId);
|
||||||
|
if ($projectId === '') {
|
||||||
|
throw new RuntimeException('Creating an issue category requires a project id or identifier.');
|
||||||
|
}
|
||||||
|
if (!isset($fields['name']) || trim((string) $fields['name']) === '') {
|
||||||
|
throw new RuntimeException('Creating an issue category requires a non-empty name.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->postJson('/projects/' . rawurlencode($projectId) . '/issue_categories', ['issue_category' => $fields]);
|
||||||
|
|
||||||
|
return is_array($response['issue_category'] ?? null) ? $response['issue_category'] : $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $fields
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function updateIssueCategory(int $categoryId, array $fields): array
|
||||||
|
{
|
||||||
|
if ($fields === []) {
|
||||||
|
throw new RuntimeException('Updating an issue category requires at least one field.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->putJson('/issue_categories/' . rawurlencode((string) $categoryId), ['issue_category' => $fields]);
|
||||||
|
|
||||||
|
return is_array($response['issue_category'] ?? null) ? $response['issue_category'] : $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function attachment(int $attachmentId): array
|
||||||
|
{
|
||||||
|
if ($attachmentId <= 0) {
|
||||||
|
throw new RuntimeException('Fetching an attachment requires a positive attachment id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->getJson('/attachments/' . rawurlencode((string) $attachmentId));
|
||||||
|
if (!is_array($response)) {
|
||||||
|
throw new RuntimeException('Could not fetch attachment #' . $attachmentId . '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response['attachment'] ?? $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $source
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function uploadAttachment(array $source): array
|
||||||
|
{
|
||||||
|
$source = $this->normalizeAttachmentUploadSource($source);
|
||||||
|
$filename = isset($source['filename']) ? trim((string) $source['filename']) : '';
|
||||||
|
$contentType = trim((string) ($source['content_type'] ?? 'application/octet-stream'));
|
||||||
|
if ($contentType === '') {
|
||||||
|
$contentType = 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($source['path'])) {
|
||||||
|
$path = (string) $source['path'];
|
||||||
|
if (!is_file($path) || !is_readable($path)) {
|
||||||
|
throw new RuntimeException('Uploading an attachment from path requires a readable local file.');
|
||||||
|
}
|
||||||
|
$bytes = file_get_contents($path);
|
||||||
|
if ($bytes === false) {
|
||||||
|
throw new RuntimeException('Could not read attachment file: ' . $path);
|
||||||
|
}
|
||||||
|
if ($filename === '') {
|
||||||
|
$filename = basename($path);
|
||||||
|
}
|
||||||
|
} elseif (isset($source['base64_content'])) {
|
||||||
|
if ($filename === '') {
|
||||||
|
throw new RuntimeException('Uploading base64 attachment content requires filename.');
|
||||||
|
}
|
||||||
|
$decoded = base64_decode((string) $source['base64_content'], true);
|
||||||
|
if ($decoded === false) {
|
||||||
|
throw new RuntimeException('Attachment base64_content is not valid base64.');
|
||||||
|
}
|
||||||
|
$bytes = $decoded;
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException('Uploading an attachment requires either path or base64_content.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($filename === '') {
|
||||||
|
throw new RuntimeException('Uploading an attachment requires filename.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->rawRequest(
|
||||||
|
'POST',
|
||||||
|
PathSerializer::create('/uploads.json', ['filename' => $filename])->getPath(),
|
||||||
|
'application/octet-stream',
|
||||||
|
$bytes
|
||||||
|
);
|
||||||
|
$status = $response->getStatusCode();
|
||||||
|
if ($status >= 400) {
|
||||||
|
throw new RuntimeException('Redmine upload failed with HTTP ' . $status . ': ' . $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded = $this->decodeJsonResponse($response, 'Redmine upload');
|
||||||
|
$upload = is_array($decoded['upload'] ?? null) ? $decoded['upload'] : $decoded;
|
||||||
|
if (isset($source['description'])) {
|
||||||
|
$upload['description'] = (string) $source['description'];
|
||||||
|
}
|
||||||
|
$upload += [
|
||||||
|
'filename' => $filename,
|
||||||
|
'content_type' => $contentType,
|
||||||
|
];
|
||||||
|
|
||||||
|
return $upload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $source
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function normalizeAttachmentUploadSource(array $source): array
|
||||||
|
{
|
||||||
|
if (isset($source['file']) && is_array($source['file'])) {
|
||||||
|
$file = $source['file'];
|
||||||
|
unset($source['file']);
|
||||||
|
foreach ([
|
||||||
|
'path' => 'path',
|
||||||
|
'filename' => 'filename',
|
||||||
|
'name' => 'filename',
|
||||||
|
'content_type' => 'content_type',
|
||||||
|
'mime_type' => 'content_type',
|
||||||
|
'mimeType' => 'content_type',
|
||||||
|
'media_type' => 'content_type',
|
||||||
|
'description' => 'description',
|
||||||
|
'base64_content' => 'base64_content',
|
||||||
|
'base64' => 'base64_content',
|
||||||
|
'data' => 'base64_content',
|
||||||
|
'blob' => 'base64_content',
|
||||||
|
'data_url' => 'data_url',
|
||||||
|
'url' => 'data_url',
|
||||||
|
] as $from => $to) {
|
||||||
|
if (!isset($source[$to]) && isset($file[$from])) {
|
||||||
|
$source[$to] = $file[$from];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([
|
||||||
|
'name' => 'filename',
|
||||||
|
'mime_type' => 'content_type',
|
||||||
|
'mimeType' => 'content_type',
|
||||||
|
'media_type' => 'content_type',
|
||||||
|
'base64' => 'base64_content',
|
||||||
|
'data' => 'base64_content',
|
||||||
|
'blob' => 'base64_content',
|
||||||
|
] as $from => $to) {
|
||||||
|
if (!isset($source[$to]) && isset($source[$from])) {
|
||||||
|
$source[$to] = $source[$from];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($source['data_url']) || (isset($source['base64_content']) && str_starts_with((string) $source['base64_content'], 'data:'))) {
|
||||||
|
$dataUrl = (string) ($source['data_url'] ?? $source['base64_content']);
|
||||||
|
$parsed = $this->parseAttachmentDataUrl($dataUrl);
|
||||||
|
$source['base64_content'] = $parsed['base64_content'];
|
||||||
|
if (!isset($source['content_type'])) {
|
||||||
|
$source['content_type'] = $parsed['content_type'];
|
||||||
|
}
|
||||||
|
if (!isset($source['filename']) || trim((string) $source['filename']) === '') {
|
||||||
|
$source['filename'] = $this->defaultAttachmentFilename((string) $source['content_type']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $source;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{content_type:string,base64_content:string}
|
||||||
|
*/
|
||||||
|
private function parseAttachmentDataUrl(string $dataUrl): array
|
||||||
|
{
|
||||||
|
if (!preg_match('/^data:([^;,]+)?(?:;[^,]*)?;base64,(.*)$/s', $dataUrl, $matches)) {
|
||||||
|
throw new RuntimeException('Attachment data_url must be a base64 data URL.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$contentType = trim($matches[1] !== '' ? $matches[1] : 'application/octet-stream');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'content_type' => $contentType !== '' ? $contentType : 'application/octet-stream',
|
||||||
|
'base64_content' => $matches[2],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultAttachmentFilename(string $contentType): string
|
||||||
|
{
|
||||||
|
return match (strtolower($contentType)) {
|
||||||
|
'application/pdf' => 'attachment.pdf',
|
||||||
|
'text/plain' => 'attachment.txt',
|
||||||
|
'text/csv' => 'attachment.csv',
|
||||||
|
'application/json' => 'attachment.json',
|
||||||
|
'image/jpeg' => 'attachment.jpg',
|
||||||
|
'image/png' => 'attachment.png',
|
||||||
|
'image/gif' => 'attachment.gif',
|
||||||
|
default => 'attachment.bin',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function updateAttachment(int $attachmentId, array $fields): array
|
||||||
|
{
|
||||||
|
if ($fields === []) {
|
||||||
|
throw new RuntimeException('Updating an attachment requires at least one field.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->putJson('/attachments/' . rawurlencode((string) $attachmentId), ['attachment' => $fields]);
|
||||||
|
|
||||||
|
return is_array($response['attachment'] ?? null) ? $response['attachment'] : $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
public function downloadAttachment(int $attachmentId, string $destinationPath, bool $includeBase64 = false, int $maxBase64Bytes = 262144): array
|
||||||
|
{
|
||||||
|
$destinationPath = $this->safeDownloadPath($destinationPath);
|
||||||
|
$response = $this->rawRequest('GET', '/attachments/download/' . rawurlencode((string) $attachmentId));
|
||||||
|
$status = $response->getStatusCode();
|
||||||
|
if ($status >= 400) {
|
||||||
|
throw new RuntimeException('Redmine attachment download failed with HTTP ' . $status . ': ' . $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes = $response->getContent();
|
||||||
|
if (file_put_contents($destinationPath, $bytes) === false) {
|
||||||
|
throw new RuntimeException('Could not write attachment download to ' . $destinationPath . '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'attachment_id' => $attachmentId,
|
||||||
|
'path' => $destinationPath,
|
||||||
|
'bytes' => strlen($bytes),
|
||||||
|
'content_type' => $response->getContentType(),
|
||||||
|
];
|
||||||
|
if ($includeBase64 && strlen($bytes) <= $maxBase64Bytes) {
|
||||||
|
$result['base64_content'] = base64_encode($bytes);
|
||||||
|
} elseif ($includeBase64) {
|
||||||
|
$result['base64_omitted'] = true;
|
||||||
|
$result['base64_limit_bytes'] = $maxBase64Bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a Helpdesk email response for an existing Helpdesk-backed issue.
|
* Send a Helpdesk email response for an existing Helpdesk-backed issue.
|
||||||
*
|
*
|
||||||
@@ -390,22 +868,67 @@ final class RedmineClient
|
|||||||
*/
|
*/
|
||||||
private function postJson(string $path, array $payload): array
|
private function postJson(string $path, array $payload): array
|
||||||
{
|
{
|
||||||
if (!$this->client instanceof HttpClient) {
|
|
||||||
throw new RuntimeException('Configured Redmine client cannot issue raw HTTP requests.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$requestPath = $this->buildPath($path, []);
|
$requestPath = $this->buildPath($path, []);
|
||||||
$encoded = json_encode($payload);
|
$encoded = json_encode($payload);
|
||||||
if ($encoded === false) {
|
if ($encoded === false) {
|
||||||
throw new RuntimeException('Could not encode Redmine POST payload.');
|
throw new RuntimeException('Could not encode Redmine POST payload.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = $this->client->request(HttpFactory::makeJsonRequest('POST', $requestPath, $encoded));
|
$response = $this->rawRequest('POST', $requestPath, 'application/json', $encoded);
|
||||||
$status = $response->getStatusCode();
|
$status = $response->getStatusCode();
|
||||||
if ($status >= 400) {
|
if ($status >= 400) {
|
||||||
throw new RuntimeException('Redmine POST ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent());
|
throw new RuntimeException('Redmine POST ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->decodeJsonResponse($response, 'Redmine POST ' . $requestPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $payload
|
||||||
|
*
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function putJson(string $path, array $payload): array
|
||||||
|
{
|
||||||
|
$requestPath = $this->buildPath($path, []);
|
||||||
|
$encoded = json_encode($payload);
|
||||||
|
if ($encoded === false) {
|
||||||
|
throw new RuntimeException('Could not encode Redmine PUT payload.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $this->rawRequest('PUT', $requestPath, 'application/json', $encoded);
|
||||||
|
$status = $response->getStatusCode();
|
||||||
|
if ($status >= 400) {
|
||||||
|
throw new RuntimeException('Redmine PUT ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->decodeJsonResponse($response, 'Redmine PUT ' . $requestPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteJson(string $path): void
|
||||||
|
{
|
||||||
|
$requestPath = $this->buildPath($path, []);
|
||||||
|
$response = $this->rawRequest('DELETE', $requestPath);
|
||||||
|
$status = $response->getStatusCode();
|
||||||
|
if ($status >= 400) {
|
||||||
|
throw new RuntimeException('Redmine DELETE ' . $requestPath . ' failed with HTTP ' . $status . ': ' . $response->getContent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function rawRequest(string $method, string $path, string $contentType = '', string $content = ''): \Redmine\Http\Response
|
||||||
|
{
|
||||||
|
if (!$this->client instanceof HttpClient) {
|
||||||
|
throw new RuntimeException('Configured Redmine client cannot issue raw HTTP requests.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->client->request(HttpFactory::makeRequest($method, $path, $contentType, $content));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
private function decodeJsonResponse(\Redmine\Http\Response $response, string $action): array
|
||||||
|
{
|
||||||
$body = $response->getContent();
|
$body = $response->getContent();
|
||||||
if ($body === '') {
|
if ($body === '') {
|
||||||
return [];
|
return [];
|
||||||
@@ -414,11 +937,11 @@ final class RedmineClient
|
|||||||
try {
|
try {
|
||||||
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
|
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
throw new RuntimeException('Redmine POST ' . $requestPath . ' returned invalid JSON.', 0, $exception);
|
throw new RuntimeException($action . ' returned invalid JSON.', 0, $exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_array($decoded)) {
|
if (!is_array($decoded)) {
|
||||||
throw new RuntimeException('Redmine POST ' . $requestPath . ' returned invalid JSON.');
|
throw new RuntimeException($action . ' returned invalid JSON.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $decoded;
|
return $decoded;
|
||||||
@@ -432,6 +955,31 @@ final class RedmineClient
|
|||||||
return PathSerializer::create($path . '.json', $params)->getPath();
|
return PathSerializer::create($path . '.json', $params)->getPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function safeDownloadPath(string $path): string
|
||||||
|
{
|
||||||
|
$path = trim($path);
|
||||||
|
if ($path === '' || str_contains($path, "\0")) {
|
||||||
|
throw new RuntimeException('Attachment download requires a safe local destination path.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$directory = dirname($path);
|
||||||
|
$realDirectory = realpath($directory);
|
||||||
|
if ($realDirectory === false || !is_dir($realDirectory)) {
|
||||||
|
throw new RuntimeException('Attachment download destination directory does not exist: ' . $directory);
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved = $realDirectory . DIRECTORY_SEPARATOR . basename($path);
|
||||||
|
$repoRoot = realpath(dirname(__DIR__, 2));
|
||||||
|
$tmpRoot = realpath(sys_get_temp_dir());
|
||||||
|
foreach (array_filter([$repoRoot, $tmpRoot]) as $allowedRoot) {
|
||||||
|
if ($resolved === $allowedRoot || str_starts_with($resolved, $allowedRoot . DIRECTORY_SEPARATOR)) {
|
||||||
|
return $resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException('Attachment downloads must write under /tmp or the repository tree.');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param mixed $api
|
* @param mixed $api
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?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'])
|
||||||
|
),
|
||||||
|
$token,
|
||||||
|
getenv('MCP_HTTP_PATH') ?: '/mcp'
|
||||||
|
);
|
||||||
|
$handler->handle();
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$options = getopt('', ['bytes:', 'env-line', 'help']);
|
||||||
|
if (isset($options['help'])) {
|
||||||
|
fwrite(STDOUT, "Usage: generate-bearer-token.php [--bytes 32] [--env-line]\n");
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes = isset($options['bytes']) ? (int) $options['bytes'] : 32;
|
||||||
|
if ($bytes < 16) {
|
||||||
|
fwrite(STDERR, "--bytes must be at least 16.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = rtrim(strtr(base64_encode(random_bytes($bytes)), '+/', '-_'), '=');
|
||||||
|
if (isset($options['env-line'])) {
|
||||||
|
fwrite(STDOUT, 'MCP_SERVER_TOKEN=' . $token . PHP_EOL);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, $token . PHP_EOL);
|
||||||
Executable
+188
@@ -0,0 +1,188 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use RedMCP\McpEnvironment;
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
$options = getopt('', ['host:', 'port:', 'path:', 'pid-file:', 'debug-log:', 'status', 'stop', 'force', 'help']);
|
||||||
|
if (isset($options['help'])) {
|
||||||
|
fwrite(
|
||||||
|
STDOUT,
|
||||||
|
"Usage: redmcp-http-server.php [--host 127.0.0.1] [--port 8765] [--path /mcp] [--pid-file /tmp/redmcp-http-server.pid] [--debug-log /tmp/redmcp-mcp.log] [--status|--stop] [--force]\n"
|
||||||
|
);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = (string) ($options['host'] ?? '127.0.0.1');
|
||||||
|
$port = (int) ($options['port'] ?? 8765);
|
||||||
|
$path = (string) ($options['path'] ?? '/mcp');
|
||||||
|
$pidFile = (string) ($options['pid-file'] ?? '/tmp/redmcp-http-server.pid');
|
||||||
|
$debugLog = isset($options['debug-log']) ? (string) $options['debug-log'] : null;
|
||||||
|
$force = isset($options['force']);
|
||||||
|
|
||||||
|
if (isset($options['status'])) {
|
||||||
|
showStatus($pidFile);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($options['stop'])) {
|
||||||
|
stopServer($pidFile);
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLivePidFile($pidFile)) {
|
||||||
|
fwrite(STDERR, "redMCP HTTP server already appears to be running with PID " . trim((string) file_get_contents($pidFile)) . ".\n");
|
||||||
|
fwrite(STDERR, "Use --stop first, or --force to replace a stale PID file.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_file($pidFile)) {
|
||||||
|
if (!$force) {
|
||||||
|
fwrite(STDERR, "Stale PID file exists at {$pidFile}. Use --force to remove it.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
unlink($pidFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$env = McpEnvironment::load(__DIR__ . '/../.env');
|
||||||
|
if ($env['mcp_server_token'] === null) {
|
||||||
|
throw new RuntimeException('MCP_SERVER_TOKEN is required for the network MCP server.');
|
||||||
|
}
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
putenv('MCP_HTTP_PATH=' . $path);
|
||||||
|
if ($debugLog !== null && $debugLog !== '') {
|
||||||
|
putenv('MCP_DEBUG_LOG=' . $debugLog);
|
||||||
|
}
|
||||||
|
$router = __DIR__ . '/../app/mcp-http-router.php';
|
||||||
|
$command = [
|
||||||
|
PHP_BINARY,
|
||||||
|
'-S',
|
||||||
|
$host . ':' . $port,
|
||||||
|
$router,
|
||||||
|
];
|
||||||
|
|
||||||
|
fwrite(STDERR, "redMCP HTTP server listening on http://{$host}:{$port}{$path}\n");
|
||||||
|
fwrite(STDERR, "Authorization: Bearer <MCP_SERVER_TOKEN> is required.\n");
|
||||||
|
if ($debugLog !== null && $debugLog !== '') {
|
||||||
|
fwrite(STDERR, "Debug log: {$debugLog}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
$descriptorSpec = [
|
||||||
|
0 => STDIN,
|
||||||
|
1 => STDOUT,
|
||||||
|
2 => STDERR,
|
||||||
|
];
|
||||||
|
$process = proc_open($command, $descriptorSpec, $pipes);
|
||||||
|
if (!is_resource($process)) {
|
||||||
|
fwrite(STDERR, "Could not start PHP built-in HTTP server.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = proc_get_status($process);
|
||||||
|
$pid = (int) ($status['pid'] ?? 0);
|
||||||
|
if ($pid <= 0) {
|
||||||
|
proc_terminate($process);
|
||||||
|
fwrite(STDERR, "Could not determine HTTP server PID.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pidDir = dirname($pidFile);
|
||||||
|
if ($pidDir !== '' && $pidDir !== '.' && !is_dir($pidDir)) {
|
||||||
|
mkdir($pidDir, 0775, true);
|
||||||
|
}
|
||||||
|
file_put_contents($pidFile, (string) $pid);
|
||||||
|
fwrite(STDERR, "PID file: {$pidFile} ({$pid})\n");
|
||||||
|
|
||||||
|
$exitCode = proc_close($process);
|
||||||
|
if (is_file($pidFile) && trim((string) file_get_contents($pidFile)) === (string) $pid) {
|
||||||
|
unlink($pidFile);
|
||||||
|
}
|
||||||
|
exit((int) $exitCode);
|
||||||
|
|
||||||
|
function showStatus(string $pidFile): void
|
||||||
|
{
|
||||||
|
if (!is_file($pidFile)) {
|
||||||
|
fwrite(STDOUT, "stopped: no PID file at {$pidFile}\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pid = (int) trim((string) file_get_contents($pidFile));
|
||||||
|
if ($pid > 0 && pidAlive($pid)) {
|
||||||
|
fwrite(STDOUT, "running: PID {$pid} from {$pidFile}\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, "stale: PID file {$pidFile} points to non-running PID {$pid}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopServer(string $pidFile): void
|
||||||
|
{
|
||||||
|
if (!is_file($pidFile)) {
|
||||||
|
fwrite(STDOUT, "stopped: no PID file at {$pidFile}\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pid = (int) trim((string) file_get_contents($pidFile));
|
||||||
|
if ($pid <= 0 || !pidAlive($pid)) {
|
||||||
|
unlink($pidFile);
|
||||||
|
fwrite(STDOUT, "removed stale PID file {$pidFile}\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stopPid($pid)) {
|
||||||
|
fwrite(STDERR, "could not stop PID {$pid}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$deadline = time() + 5;
|
||||||
|
while (pidAlive($pid) && time() < $deadline) {
|
||||||
|
usleep(100000);
|
||||||
|
}
|
||||||
|
if (pidAlive($pid)) {
|
||||||
|
fwrite(STDERR, "PID {$pid} did not stop within timeout\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_file($pidFile)) {
|
||||||
|
unlink($pidFile);
|
||||||
|
}
|
||||||
|
fwrite(STDOUT, "stopped PID {$pid}\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLivePidFile(string $pidFile): bool
|
||||||
|
{
|
||||||
|
if (!is_file($pidFile)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pid = (int) trim((string) file_get_contents($pidFile));
|
||||||
|
return $pid > 0 && pidAlive($pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pidAlive(int $pid): bool
|
||||||
|
{
|
||||||
|
if (function_exists('posix_kill')) {
|
||||||
|
return posix_kill($pid, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
exec('kill -0 ' . escapeshellarg((string) $pid) . ' 2>/dev/null', $output, $exitCode);
|
||||||
|
return $exitCode === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPid(int $pid): bool
|
||||||
|
{
|
||||||
|
if (function_exists('posix_kill')) {
|
||||||
|
return posix_kill($pid, 15);
|
||||||
|
}
|
||||||
|
|
||||||
|
exec('kill ' . escapeshellarg((string) $pid) . ' 2>/dev/null', $output, $exitCode);
|
||||||
|
return $exitCode === 0;
|
||||||
|
}
|
||||||
@@ -3,11 +3,24 @@
|
|||||||
|
|
||||||
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'])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$server->run();
|
||||||
|
exit(0);
|
||||||
|
__halt_compiler();
|
||||||
|
|
||||||
function main(): void
|
function main(): void
|
||||||
{
|
{
|
||||||
|
|||||||
Executable
+164
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
$repoRoot = dirname(__DIR__);
|
||||||
|
$tmpDir = sys_get_temp_dir() . '/redmcp-http-test-' . getmypid();
|
||||||
|
if (!mkdir($tmpDir, 0775, true) && !is_dir($tmpDir)) {
|
||||||
|
throw new RuntimeException('Could not create temp test dir.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$router = $tmpDir . '/router.php';
|
||||||
|
file_put_contents($router, <<<'PHP'
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use RedMCP\McpDispatcher;
|
||||||
|
use RedMCP\McpHttpHandler;
|
||||||
|
use RedMCP\RedmineClient;
|
||||||
|
|
||||||
|
require '%AUTOLOAD%';
|
||||||
|
|
||||||
|
$handler = new McpHttpHandler(
|
||||||
|
new McpDispatcher(RedmineClient::fromCredentials('http://127.0.0.1', 'test-key')),
|
||||||
|
'test-token',
|
||||||
|
'/mcp'
|
||||||
|
);
|
||||||
|
$handler->handle();
|
||||||
|
PHP);
|
||||||
|
file_put_contents($router, str_replace('%AUTOLOAD%', addslashes($repoRoot . '/vendor/autoload.php'), (string) file_get_contents($router)));
|
||||||
|
|
||||||
|
$port = 18765 + (getmypid() % 1000);
|
||||||
|
$command = [PHP_BINARY, '-S', '127.0.0.1:' . $port, $router];
|
||||||
|
$process = proc_open($command, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $repoRoot);
|
||||||
|
if (!is_resource($process)) {
|
||||||
|
throw new RuntimeException('Could not start PHP built-in server.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$assertions = 0;
|
||||||
|
try {
|
||||||
|
waitForServer($port);
|
||||||
|
|
||||||
|
$sse = httpRequest(
|
||||||
|
'POST',
|
||||||
|
'http://127.0.0.1:' . $port . '/mcp',
|
||||||
|
[
|
||||||
|
'Authorization: Bearer test-token',
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Accept: application/json, text/event-stream',
|
||||||
|
],
|
||||||
|
'{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}'
|
||||||
|
);
|
||||||
|
assertContains('HTTP/1.1 200 OK', $sse['headers'], 'SSE POST returns 200', $assertions);
|
||||||
|
assertContains('text/event-stream', $sse['headers'], 'SSE POST returns event stream content type', $assertions);
|
||||||
|
assertContains('X-Accel-Buffering: no', $sse['headers'], 'SSE POST disables proxy buffering', $assertions);
|
||||||
|
assertContains("event: message\n", $sse['body'], 'SSE POST emits a message event', $assertions);
|
||||||
|
assertContains('data: {"jsonrpc":"2.0","id":1,"result":[]}', $sse['body'], 'SSE POST emits JSON-RPC response data', $assertions);
|
||||||
|
|
||||||
|
$json = httpRequest(
|
||||||
|
'POST',
|
||||||
|
'http://127.0.0.1:' . $port . '/mcp',
|
||||||
|
[
|
||||||
|
'Authorization: Bearer test-token',
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Accept: application/json',
|
||||||
|
],
|
||||||
|
'{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}'
|
||||||
|
);
|
||||||
|
assertContains('application/json', $json['headers'], 'JSON POST preserves application/json content type', $assertions);
|
||||||
|
assertContains('"id":2', $json['body'], 'JSON POST emits JSON-RPC response body', $assertions);
|
||||||
|
|
||||||
|
$get = httpRequest(
|
||||||
|
'GET',
|
||||||
|
'http://127.0.0.1:' . $port . '/mcp',
|
||||||
|
[
|
||||||
|
'Authorization: Bearer test-token',
|
||||||
|
'Accept: text/event-stream',
|
||||||
|
],
|
||||||
|
null
|
||||||
|
);
|
||||||
|
assertContains('HTTP/1.1 405 Method Not Allowed', $get['headers'], 'GET returns method-not-allowed until standalone streams exist', $assertions);
|
||||||
|
assertContains('Allow: POST', $get['headers'], 'GET advertises supported method', $assertions);
|
||||||
|
|
||||||
|
$origin = httpRequest(
|
||||||
|
'POST',
|
||||||
|
'http://127.0.0.1:' . $port . '/mcp',
|
||||||
|
[
|
||||||
|
'Authorization: Bearer test-token',
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Accept: application/json',
|
||||||
|
'Origin: https://example.invalid',
|
||||||
|
],
|
||||||
|
'{"jsonrpc":"2.0","id":3,"method":"ping","params":{}}'
|
||||||
|
);
|
||||||
|
assertContains('HTTP/1.1 403 Forbidden', $origin['headers'], 'disallowed browser origin returns forbidden', $assertions);
|
||||||
|
|
||||||
|
fwrite(STDOUT, "OK {$assertions} assertions\n");
|
||||||
|
} finally {
|
||||||
|
proc_terminate($process);
|
||||||
|
proc_close($process);
|
||||||
|
foreach ($pipes as $pipe) {
|
||||||
|
if (is_resource($pipe)) {
|
||||||
|
fclose($pipe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@unlink($router);
|
||||||
|
@rmdir($tmpDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForServer(int $port): void
|
||||||
|
{
|
||||||
|
$deadline = microtime(true) + 5;
|
||||||
|
while (microtime(true) < $deadline) {
|
||||||
|
$socket = @fsockopen('127.0.0.1', $port, $errno, $errstr, 0.1);
|
||||||
|
if (is_resource($socket)) {
|
||||||
|
fclose($socket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
usleep(100000);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException('Timed out waiting for test HTTP server.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,string> $headers
|
||||||
|
* @return array{headers:string,body:string}
|
||||||
|
*/
|
||||||
|
function httpRequest(string $method, string $url, array $headers, ?string $body): array
|
||||||
|
{
|
||||||
|
$curl = curl_init($url);
|
||||||
|
if ($curl === false) {
|
||||||
|
throw new RuntimeException('Could not initialize curl.');
|
||||||
|
}
|
||||||
|
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
|
||||||
|
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
|
||||||
|
curl_setopt($curl, CURLOPT_HEADER, true);
|
||||||
|
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
if ($body !== null) {
|
||||||
|
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
|
||||||
|
}
|
||||||
|
$raw = curl_exec($curl);
|
||||||
|
if (!is_string($raw)) {
|
||||||
|
throw new RuntimeException('curl failed: ' . curl_error($curl));
|
||||||
|
}
|
||||||
|
$headerSize = (int) curl_getinfo($curl, CURLINFO_HEADER_SIZE);
|
||||||
|
curl_close($curl);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'headers' => substr($raw, 0, $headerSize),
|
||||||
|
'body' => substr($raw, $headerSize),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertContains(string $needle, string $haystack, string $message, int &$assertions): void
|
||||||
|
{
|
||||||
|
$assertions++;
|
||||||
|
if (strpos($haystack, $needle) !== false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "FAIL: {$message}\nNeedle: {$needle}\nHaystack: {$haystack}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
Executable
+142
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use RedMCP\ListQueryNormalizer;
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
final class QueryNormalizerTest
|
||||||
|
{
|
||||||
|
private int $assertions = 0;
|
||||||
|
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$this->testDefaultPagingAndRawOverride();
|
||||||
|
$this->testIssueFriendlyFilters();
|
||||||
|
$this->testSortShortcutsAndStructuredSort();
|
||||||
|
$this->testDatePresetsAndRanges();
|
||||||
|
$this->testUserParams();
|
||||||
|
$this->testSearchParams();
|
||||||
|
|
||||||
|
fwrite(STDOUT, "OK {$this->assertions} assertions\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testDefaultPagingAndRawOverride(): void
|
||||||
|
{
|
||||||
|
$params = ListQueryNormalizer::listParams([
|
||||||
|
'limit' => 500,
|
||||||
|
'page' => 3,
|
||||||
|
'params' => ['limit' => 75],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(75, $params['limit'], 'raw params override normalized limit');
|
||||||
|
$this->assertSame(200, $params['offset'], 'page offset uses clamped normalized limit before raw override');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testIssueFriendlyFilters(): void
|
||||||
|
{
|
||||||
|
$filters = ListQueryNormalizer::issueFilters([
|
||||||
|
'project_id' => 'customer-service',
|
||||||
|
'status' => 'open',
|
||||||
|
'assigned_to_id' => 25,
|
||||||
|
'updated' => 'since 2026-04-01',
|
||||||
|
'filters' => ['assigned_to_id' => 'me'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('customer-service', $filters['project_id'], 'project_id is copied');
|
||||||
|
$this->assertSame('open', $filters['status_id'], 'open status alias maps to Redmine open status');
|
||||||
|
$this->assertSame('me', $filters['assigned_to_id'], 'raw filters override normalized filters');
|
||||||
|
$this->assertSame('>=2026-04-01', $filters['updated_on'], 'since date maps to Redmine lower bound');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testSortShortcutsAndStructuredSort(): void
|
||||||
|
{
|
||||||
|
$newest = ListQueryNormalizer::listParams(['sort' => 'newest']);
|
||||||
|
$this->assertSame('updated_on:desc', $newest['sort'], 'newest shortcut sorts by updated_on descending');
|
||||||
|
|
||||||
|
$priority = ListQueryNormalizer::listParams(['sort' => 'priority']);
|
||||||
|
$this->assertSame('priority:desc,updated_on:desc', $priority['sort'], 'priority shortcut includes updated_on tie-breaker');
|
||||||
|
|
||||||
|
$structured = ListQueryNormalizer::listParams([
|
||||||
|
'sort' => [
|
||||||
|
['field' => 'created_on', 'direction' => 'desc'],
|
||||||
|
['field' => 'id', 'direction' => 'asc'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
$this->assertSame('created_on:desc,id:asc', $structured['sort'], 'structured sort converts to Redmine sort string');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testDatePresetsAndRanges(): void
|
||||||
|
{
|
||||||
|
$clock = new DateTimeImmutable('2026-04-25 12:00:00', new DateTimeZone('UTC'));
|
||||||
|
|
||||||
|
$today = ListQueryNormalizer::issueFilters(['created' => 'today'], $clock);
|
||||||
|
$this->assertSame('2026-04-25', $today['created_on'], 'today maps to exact date');
|
||||||
|
|
||||||
|
$range = ListQueryNormalizer::issueFilters(['created' => '2026-04-01..2026-04-25'], $clock);
|
||||||
|
$this->assertSame('><2026-04-01|2026-04-25', $range['created_on'], 'range string maps to Redmine between syntax');
|
||||||
|
|
||||||
|
$objectRange = ListQueryNormalizer::issueFilters(['due' => ['from' => '2026-05-01', 'to' => '2026-05-31']], $clock);
|
||||||
|
$this->assertSame('><2026-05-01|2026-05-31', $objectRange['due_date'], 'object range maps to due_date');
|
||||||
|
|
||||||
|
$lastSeven = ListQueryNormalizer::issueFilters(['updated' => 'last 7 days'], $clock);
|
||||||
|
$this->assertSame('><2026-04-19|2026-04-25', $lastSeven['updated_on'], 'last N days includes today');
|
||||||
|
|
||||||
|
$freeText = ListQueryNormalizer::issueFilters(['created' => 'April 2 2026'], $clock);
|
||||||
|
$this->assertSame('2026-04-02', $freeText['created_on'], 'simple free text date parses');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testSearchParams(): void
|
||||||
|
{
|
||||||
|
$params = ListQueryNormalizer::searchParams([
|
||||||
|
'project_id' => 'customer-service',
|
||||||
|
'all_words' => true,
|
||||||
|
'titles_only' => false,
|
||||||
|
'open_issues' => true,
|
||||||
|
'sort' => 'oldest',
|
||||||
|
'page' => 2,
|
||||||
|
'params' => ['offset' => 5],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('customer-service', $params['project_id'], 'search project_id is copied');
|
||||||
|
$this->assertSame('1', $params['all_words'], 'true search flags map to Redmine 1');
|
||||||
|
$this->assertSame('0', $params['titles_only'], 'false search flags map to Redmine 0');
|
||||||
|
$this->assertSame('1', $params['open_issues'], 'open_issues flag maps to Redmine 1');
|
||||||
|
$this->assertSame('created_on:asc', $params['sort'], 'oldest shortcut maps to created_on ascending');
|
||||||
|
$this->assertSame(5, $params['offset'], 'raw search params override normalized paging');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testUserParams(): void
|
||||||
|
{
|
||||||
|
$params = ListQueryNormalizer::userParams([
|
||||||
|
'status' => 'active',
|
||||||
|
'name' => 'danny',
|
||||||
|
'group_id' => 4,
|
||||||
|
'sort' => 'oldest',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame(1, $params['status'], 'active user status maps to Redmine active status id');
|
||||||
|
$this->assertSame('danny', $params['name'], 'user name filter is copied');
|
||||||
|
$this->assertSame(4, $params['group_id'], 'user group filter is copied');
|
||||||
|
$this->assertSame('created_on:asc', $params['sort'], 'user list accepts sort shortcuts');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $expected
|
||||||
|
* @param mixed $actual
|
||||||
|
*/
|
||||||
|
private function assertSame($expected, $actual, string $message): void
|
||||||
|
{
|
||||||
|
$this->assertions++;
|
||||||
|
if ($expected === $actual) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "FAIL: {$message}\nExpected: " . var_export($expected, true) . "\nActual: " . var_export($actual, true) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(new QueryNormalizerTest())->run();
|
||||||
Executable
+550
@@ -0,0 +1,550 @@
|
|||||||
|
#!/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->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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,string> $haystack
|
||||||
|
*/
|
||||||
|
private function assertContains(string $needle, array $haystack, string $message): void
|
||||||
|
{
|
||||||
|
$this->assertions++;
|
||||||
|
if (in_array($needle, $haystack, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "FAIL: {$message}\nMissing: {$needle}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int,string> $haystack
|
||||||
|
*/
|
||||||
|
private function assertNotContains(string $needle, array $haystack, string $message): void
|
||||||
|
{
|
||||||
|
$this->assertions++;
|
||||||
|
if (!in_array($needle, $haystack, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "FAIL: {$message}\nUnexpected: {$needle}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertThrows(callable $callback, string $expectedMessagePart, string $message): void
|
||||||
|
{
|
||||||
|
$this->assertions++;
|
||||||
|
try {
|
||||||
|
$callback();
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
if (strpos($exception->getMessage(), $expectedMessagePart) !== false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "FAIL: {$message}\nExpected exception containing: {$expectedMessagePart}\nActual: {$exception->getMessage()}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDERR, "FAIL: {$message}\nExpected exception was not thrown.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(new RedmineStructureTest())->run();
|
||||||
@@ -8,7 +8,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bin": [
|
"bin": [
|
||||||
"bin/redmcp-server.php"
|
"bin/redmcp-server.php",
|
||||||
|
"bin/redmcp-http-server.php",
|
||||||
|
"bin/generate-bearer-token.php",
|
||||||
|
"bin/test-query-normalizer.php",
|
||||||
|
"bin/test-redmine-structure.php",
|
||||||
|
"bin/test-mcp-http-handler.php"
|
||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"kbsali/redmine-api": "^2.9"
|
"kbsali/redmine-api": "^2.9"
|
||||||
|
|||||||
+57
-53
@@ -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]}"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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("'", "'\"'\"'") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
OPENAI_API_KEY=
|
||||||
|
QDRANT_URL=http://localhost:6333
|
||||||
|
QDRANT_API_KEY=
|
||||||
|
QDRANT_COLLECTION=redmine_semantic_sample
|
||||||
|
REDMINE_URL=http://192.168.50.170
|
||||||
|
REDMINE_API_KEY=
|
||||||
|
REDMINE_PROJECT_IDENTIFIER=fud-helpdesk
|
||||||
|
REDMINE_SAMPLE_LIMIT=500
|
||||||
|
SEMANTIC_INDEX_HOST=127.0.0.1
|
||||||
|
SEMANTIC_INDEX_PORT=8787
|
||||||
|
SEMANTIC_INDEX_API_KEY=
|
||||||
|
SEMANTIC_INDEX_REFRESH_STATE_PATH=.cache/semantic_index/refresh_state.json
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
# Redmine Semantic Index
|
||||||
|
|
||||||
|
Local semantic index service for a recent Redmine Helpdesk sample. V1 uses
|
||||||
|
OpenAI `text-embedding-3-small` embeddings and Qdrant vectors, with Redmine as
|
||||||
|
the first source adapter.
|
||||||
|
|
||||||
|
For deploy, validation, and rollback steps, see
|
||||||
|
`docs/semantic_index_deployment_runbook.md`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and set local secrets there. Do not commit `.env`.
|
||||||
|
|
||||||
|
Required for live use:
|
||||||
|
|
||||||
|
- `OPENAI_API_KEY`
|
||||||
|
- `QDRANT_URL`
|
||||||
|
- `REDMINE_URL`
|
||||||
|
- `REDMINE_API_KEY`
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- `QDRANT_API_KEY`
|
||||||
|
- `QDRANT_COLLECTION`
|
||||||
|
- `REDMINE_PROJECT_IDENTIFIER`
|
||||||
|
- `REDMINE_SAMPLE_LIMIT`
|
||||||
|
- `SEMANTIC_INDEX_API_KEY`
|
||||||
|
|
||||||
|
## HTTP
|
||||||
|
|
||||||
|
Install runtime dependencies in your chosen environment:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pip install openai qdrant-client fastapi uvicorn
|
||||||
|
```
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
uvicorn semantic_index.app:app --host 127.0.0.1 --port 8787
|
||||||
|
```
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
|
||||||
|
- `GET /health`
|
||||||
|
- `POST /sources/redmine/backfill-sample`
|
||||||
|
- `POST /search`
|
||||||
|
- `GET /documents/{id}`
|
||||||
|
- `GET /projects`
|
||||||
|
|
||||||
|
If `SEMANTIC_INDEX_API_KEY` is set, pass `Authorization: Bearer <key>`.
|
||||||
|
|
||||||
|
Search response shape is shared by HTTP, MCP, and the Python client:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"query": "candidate follow up",
|
||||||
|
"filters": {"project_identifier": "hiring", "limit": 5},
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"id": "redmine:issue:123:chunk:0",
|
||||||
|
"score": 0.72,
|
||||||
|
"snippet": "Candidate follow up...",
|
||||||
|
"payload": {},
|
||||||
|
"citation": {
|
||||||
|
"source": "redmine",
|
||||||
|
"doc_type": "issue",
|
||||||
|
"issue_id": 123,
|
||||||
|
"project_identifier": "hiring",
|
||||||
|
"url": "http://redmine/issues/123"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP examples:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS -H "Authorization: Bearer $SEMANTIC_INDEX_API_KEY" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"query":"candidate follow up","project_identifier":"hiring","limit":5}' \
|
||||||
|
http://127.0.0.1:8787/search
|
||||||
|
|
||||||
|
curl -sS -H "Authorization: Bearer $SEMANTIC_INDEX_API_KEY" \
|
||||||
|
http://127.0.0.1:8787/projects
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python Client
|
||||||
|
|
||||||
|
Use the client in-process when running from this repo/environment:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from semantic_index.client import SemanticIndexClient
|
||||||
|
|
||||||
|
client = SemanticIndexClient.local()
|
||||||
|
results = client.search("callum@safetagtracking.com", project_identifier="customer-service", limit=5)
|
||||||
|
document = client.get_document(results["results"][0]["id"])
|
||||||
|
```
|
||||||
|
|
||||||
|
Use HTTP mode from another local program:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from semantic_index.client import SemanticIndexClient
|
||||||
|
|
||||||
|
client = SemanticIndexClient(base_url="http://127.0.0.1:8787", api_key="...")
|
||||||
|
results = client.search("candidate follow up", project_identifier="hiring", limit=5)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backfill
|
||||||
|
|
||||||
|
Refresh the configured Redmine sample from the command line:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m semantic_index --backfill-redmine-sample --limit 50
|
||||||
|
```
|
||||||
|
|
||||||
|
When `REDMINE_PROJECT_IDENTIFIER` is set, the rebuild deletes and replaces only
|
||||||
|
indexed Redmine documents for that project. Without a project identifier, it
|
||||||
|
rebuilds the Redmine source sample for the collection.
|
||||||
|
|
||||||
|
Refresh a balanced multi-project sample:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m semantic_index --backfill-redmine-projects \
|
||||||
|
--projects customer-service,hiring,todo-jason,sales-inbox,business-development,dock-scheduling,prep-standardization \
|
||||||
|
--per-project-limit 100
|
||||||
|
```
|
||||||
|
|
||||||
|
Use project-specific limits when Customer Service should stay larger than the
|
||||||
|
internal project sample:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m semantic_index --backfill-redmine-projects \
|
||||||
|
--project-limits customer-service=500,hiring=200,todo-jason=200,sales-inbox=100,business-development=100,dock-scheduling=100,prep-standardization=100
|
||||||
|
```
|
||||||
|
|
||||||
|
Multi-project backfill rebuilds each project scope independently. Non-Helpdesk
|
||||||
|
projects are indexed as ordinary Redmine issues and journals; they are not
|
||||||
|
expected to have Helpdesk contact metadata.
|
||||||
|
|
||||||
|
## Rolling Refresh
|
||||||
|
|
||||||
|
Use rolling refresh for routine updates after an initial backfill:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m semantic_index --refresh-redmine-projects \
|
||||||
|
--project-limits customer-service=500,hiring=200,todo-jason=200,sales-inbox=100,business-development=100,dock-scheduling=100,prep-standardization=100 \
|
||||||
|
--dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Dry-run reports what would change without calling OpenAI or writing to Qdrant.
|
||||||
|
Remove `--dry-run` to apply the refresh.
|
||||||
|
|
||||||
|
The refresh maps each recent Redmine issue to stable document IDs, reads the
|
||||||
|
existing Qdrant payloads for that issue, and compares `source_hash` values.
|
||||||
|
Only new or changed documents are embedded and upserted. Unchanged documents
|
||||||
|
are left alone, and stale documents for refreshed issues are deleted without
|
||||||
|
embedding. Use `--force-rebuild` only when you explicitly want to re-embed
|
||||||
|
matching documents.
|
||||||
|
|
||||||
|
The default local state file is `.cache/semantic_index/refresh_state.json`.
|
||||||
|
After a successful refresh, later runs skip issues older than the previous
|
||||||
|
success timestamp minus `--overlap-minutes` unless `--force-rebuild` is used.
|
||||||
|
Override it with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m semantic_index --refresh-redmine-projects \
|
||||||
|
--project-limits customer-service=500 \
|
||||||
|
--state-path /tmp/semantic-refresh-state.json
|
||||||
|
```
|
||||||
|
|
||||||
|
The HTTP endpoint exposes the same behavior:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS -X POST http://127.0.0.1:8787/sources/redmine/refresh \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"project_limits":{"customer-service":500},"dry_run":true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
For production-style operation, use the wrapper script. It defaults to dry-run
|
||||||
|
and writes timestamped logs under `.cache/semantic_index/logs`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
semantic_index/refresh.sh
|
||||||
|
semantic_index/refresh.sh --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
For a quick smoke check of the wrapper path:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS='customer-service=5' semantic_index/refresh.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Override project limits, state path, or log location through environment
|
||||||
|
variables:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS='customer-service=500,hiring=200' \
|
||||||
|
SEMANTIC_INDEX_LOG_DIR=/var/log/semantic-index \
|
||||||
|
SEMANTIC_INDEX_STATE_PATH=/var/lib/semantic-index/refresh_state.json \
|
||||||
|
semantic_index/refresh.sh --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not schedule `--force-rebuild`. Force rebuilds should stay manual because
|
||||||
|
they intentionally re-embed unchanged documents.
|
||||||
|
|
||||||
|
## MCP Stdio
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m semantic_index --mcp-stdio
|
||||||
|
```
|
||||||
|
|
||||||
|
Tools:
|
||||||
|
|
||||||
|
- `semantic_search`
|
||||||
|
- `semantic_get_document`
|
||||||
|
- `semantic_list_projects`
|
||||||
|
- `semantic_backfill_redmine_sample`
|
||||||
|
- `semantic_refresh_redmine`
|
||||||
|
|
||||||
|
For agent workflows, list projects first when the user has not named a project,
|
||||||
|
search broadly or with `project_identifier` when known, then call
|
||||||
|
`semantic_get_document` for any promising result. Treat returned citations and
|
||||||
|
Redmine URLs as the authoritative references. Backfill tools are operational and
|
||||||
|
should not be part of normal search behavior.
|
||||||
|
|
||||||
|
## Inspection CLI
|
||||||
|
|
||||||
|
Use the inspect commands before larger backfills to see what is already indexed
|
||||||
|
or preview what Redmine would produce without writing to Qdrant.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -m semantic_index inspect count --source redmine --project customer-service
|
||||||
|
python3 -m semantic_index inspect list --limit 20 --source redmine --project customer-service
|
||||||
|
python3 -m semantic_index inspect search "order status" --limit 5 --project customer-service
|
||||||
|
python3 -m semantic_index inspect search "customer@example.com" --limit 5 --project customer-service
|
||||||
|
python3 -m semantic_index inspect show redmine:issue:39778:chunk:0
|
||||||
|
python3 -m semantic_index inspect preview-redmine --limit 10 --project customer-service
|
||||||
|
python3 -m semantic_index inspect audit --source redmine --project customer-service --limit 500
|
||||||
|
python3 -m semantic_index inspect compare-redmine --project customer-service --limit 20
|
||||||
|
python3 -m semantic_index inspect smoke-search --project customer-service
|
||||||
|
```
|
||||||
|
|
||||||
|
`count`, `list`, `show`, and `preview-redmine` do not call OpenAI.
|
||||||
|
`search` embeds the query text. List/search output shows snippets by default;
|
||||||
|
pass `--full-text` when you need the full indexed text.
|
||||||
|
`audit` summarizes indexed document coverage without calling OpenAI.
|
||||||
|
`compare-redmine` previews live Redmine chunks and compares them to indexed
|
||||||
|
Qdrant documents without writing to Qdrant. `smoke-search` runs known search
|
||||||
|
checks and calls OpenAI for query embeddings. Pass `--json` to `audit`,
|
||||||
|
`compare-redmine`, or `smoke-search` for machine-readable output.
|
||||||
|
For mixed project samples, run `audit` without `--project` to see project-level
|
||||||
|
counts and Helpdesk-contact coverage separately from ordinary internal issues.
|
||||||
|
|
||||||
|
For Helpdesk tickets, Redmine issue ingestion expects
|
||||||
|
`/issues/:id.json?include=journals,helpdesk` to return `helpdesk_ticket`
|
||||||
|
metadata with an expanded contact. See
|
||||||
|
`docs/redmine_issue_api_helpdesk_include.md` for the Redmine API patch notes.
|
||||||
|
|
||||||
|
## Qdrant
|
||||||
|
|
||||||
|
For local Docker-hosted Qdrant:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -p 6333:6333 -p 6334:6334 -v qdrant_storage:/qdrant/storage qdrant/qdrant
|
||||||
|
```
|
||||||
|
|
||||||
|
Create snapshots with Qdrant's snapshot API or mounted storage tooling before
|
||||||
|
destructive maintenance. The default collection name is
|
||||||
|
`redmine_semantic_sample`.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Local semantic index service for Redmine and future source adapters."""
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"config",
|
||||||
|
"embeddings",
|
||||||
|
"ingest",
|
||||||
|
"mcp",
|
||||||
|
"models",
|
||||||
|
"qdrant_store",
|
||||||
|
"redmine",
|
||||||
|
"search",
|
||||||
|
]
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
from .app import build_services
|
||||||
|
from .config import Settings, load_settings
|
||||||
|
from .inspect import (
|
||||||
|
print_audit,
|
||||||
|
print_compare_redmine,
|
||||||
|
print_count,
|
||||||
|
print_list,
|
||||||
|
print_preview_redmine,
|
||||||
|
print_search,
|
||||||
|
print_show,
|
||||||
|
print_smoke_search,
|
||||||
|
)
|
||||||
|
from .mcp import SemanticMCP, serve_stdio
|
||||||
|
from .refresh import FileRefreshState
|
||||||
|
from .redmine import RedmineApiSource
|
||||||
|
|
||||||
|
|
||||||
|
def build_preview_services(settings: Settings) -> Dict[str, object]:
|
||||||
|
return {
|
||||||
|
"settings": settings,
|
||||||
|
"redmine_source": RedmineApiSource(
|
||||||
|
redmine_url=settings.redmine_url,
|
||||||
|
api_key=settings.redmine_api_key or "",
|
||||||
|
project_identifier=settings.redmine_project_identifier,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_projects(raw: str) -> List[str]:
|
||||||
|
return [project.strip() for project in raw.split(",") if project.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_project_limits(raw: str) -> Dict[str, int]:
|
||||||
|
project_limits: Dict[str, int] = {}
|
||||||
|
for item in raw.split(","):
|
||||||
|
if not item.strip():
|
||||||
|
continue
|
||||||
|
project, limit = item.split("=", 1)
|
||||||
|
project_limits[project.strip()] = int(limit.strip())
|
||||||
|
return project_limits
|
||||||
|
|
||||||
|
|
||||||
|
def main(
|
||||||
|
argv: Optional[List[str]] = None,
|
||||||
|
service_builder: Callable[[], Dict[str, object]] = build_services,
|
||||||
|
preview_service_builder: Optional[Callable[[Settings], Dict[str, object]]] = None,
|
||||||
|
settings_loader: Callable[[], Settings] = load_settings,
|
||||||
|
) -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Semantic index helper", allow_abbrev=False)
|
||||||
|
parser.add_argument("--mcp-stdio", action="store_true", help="Run the MCP-compatible stdio tool server")
|
||||||
|
parser.add_argument("--backfill-redmine-sample", action="store_true", help="Backfill the configured Redmine sample")
|
||||||
|
parser.add_argument("--backfill-redmine-projects", action="store_true", help="Backfill multiple Redmine projects")
|
||||||
|
parser.add_argument("--refresh-redmine-projects", action="store_true", help="Refresh recent Redmine issues without re-embedding unchanged documents")
|
||||||
|
parser.add_argument("--projects", help="Comma-separated Redmine project identifiers for multi-project backfill")
|
||||||
|
parser.add_argument("--project-limits", help="Comma-separated project=limit pairs for multi-project backfill")
|
||||||
|
parser.add_argument("--per-project-limit", type=int, default=500)
|
||||||
|
parser.add_argument("--limit", type=int, default=500)
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Report planned refresh work without embeddings or writes")
|
||||||
|
parser.add_argument("--force-rebuild", action="store_true", help="Embed and upsert refresh candidates even when source hashes match")
|
||||||
|
parser.add_argument("--overlap-minutes", type=int, default=15, help="Refresh overlap window for rolling update state")
|
||||||
|
parser.add_argument("--state-path", help="Override rolling refresh state file path")
|
||||||
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
|
||||||
|
inspect_parser = subparsers.add_parser("inspect", help="Inspect indexed documents and preview Redmine chunks")
|
||||||
|
inspect_subparsers = inspect_parser.add_subparsers(dest="inspect_command", required=True)
|
||||||
|
|
||||||
|
def add_filters(command_parser: argparse.ArgumentParser) -> None:
|
||||||
|
command_parser.add_argument("--source", default="redmine")
|
||||||
|
command_parser.add_argument("--project", dest="project_identifier")
|
||||||
|
command_parser.add_argument("--doc-type")
|
||||||
|
|
||||||
|
count_parser = inspect_subparsers.add_parser("count", help="Count indexed documents")
|
||||||
|
add_filters(count_parser)
|
||||||
|
|
||||||
|
list_parser = inspect_subparsers.add_parser("list", help="List indexed documents")
|
||||||
|
add_filters(list_parser)
|
||||||
|
list_parser.add_argument("--limit", type=int, default=20)
|
||||||
|
list_parser.add_argument("--full-text", action="store_true")
|
||||||
|
|
||||||
|
search_parser = inspect_subparsers.add_parser("search", help="Search indexed documents")
|
||||||
|
search_parser.add_argument("query")
|
||||||
|
add_filters(search_parser)
|
||||||
|
search_parser.add_argument("--limit", type=int, default=10)
|
||||||
|
search_parser.add_argument("--full-text", action="store_true")
|
||||||
|
|
||||||
|
show_parser = inspect_subparsers.add_parser("show", help="Show one indexed document")
|
||||||
|
show_parser.add_argument("document_id")
|
||||||
|
|
||||||
|
preview_parser = inspect_subparsers.add_parser("preview-redmine", help="Preview Redmine chunks without writing to Qdrant")
|
||||||
|
preview_parser.add_argument("--limit", type=int, default=10)
|
||||||
|
preview_parser.add_argument("--project", dest="project_identifier")
|
||||||
|
preview_parser.add_argument("--full-text", action="store_true")
|
||||||
|
|
||||||
|
audit_parser = inspect_subparsers.add_parser("audit", help="Audit indexed documents for trust-check coverage")
|
||||||
|
add_filters(audit_parser)
|
||||||
|
audit_parser.add_argument("--limit", type=int, default=500)
|
||||||
|
audit_parser.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
compare_parser = inspect_subparsers.add_parser("compare-redmine", help="Compare live Redmine preview chunks with indexed documents")
|
||||||
|
compare_parser.add_argument("--limit", type=int, default=20)
|
||||||
|
compare_parser.add_argument("--project", dest="project_identifier")
|
||||||
|
compare_parser.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
smoke_parser = inspect_subparsers.add_parser("smoke-search", help="Run repeatable search checks against indexed documents")
|
||||||
|
smoke_parser.add_argument("--project", dest="project_identifier")
|
||||||
|
smoke_parser.add_argument("--email", default="callum@safetagtracking.com")
|
||||||
|
smoke_parser.add_argument("--issue-id", type=int, default=39779)
|
||||||
|
smoke_parser.add_argument("--order-token")
|
||||||
|
smoke_parser.add_argument("--natural-query", default="customer needs goods returned")
|
||||||
|
smoke_parser.add_argument("--json", action="store_true")
|
||||||
|
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
if not args.command and not args.backfill_redmine_sample and not args.backfill_redmine_projects and not args.refresh_redmine_projects and not args.mcp_stdio:
|
||||||
|
parser.print_help()
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == "inspect" and args.inspect_command == "preview-redmine":
|
||||||
|
if preview_service_builder is not None:
|
||||||
|
services = preview_service_builder(settings_loader())
|
||||||
|
elif service_builder is build_services:
|
||||||
|
services = build_preview_services(settings_loader())
|
||||||
|
else:
|
||||||
|
services = service_builder()
|
||||||
|
project = args.project_identifier or services["settings"].redmine_project_identifier
|
||||||
|
print_preview_redmine(services["redmine_source"], services["settings"].redmine_url, project, args.limit, args.full_text)
|
||||||
|
return
|
||||||
|
|
||||||
|
services = service_builder()
|
||||||
|
if args.state_path and "refresh" in services and hasattr(services["refresh"], "state"):
|
||||||
|
services["refresh"].state = FileRefreshState(Path(args.state_path))
|
||||||
|
if args.backfill_redmine_sample:
|
||||||
|
print(services["backfill"].backfill_redmine_sample(limit=args.limit))
|
||||||
|
return
|
||||||
|
if args.backfill_redmine_projects:
|
||||||
|
if args.project_limits:
|
||||||
|
print(services["backfill"].backfill_redmine_project_limits(parse_project_limits(args.project_limits)))
|
||||||
|
return
|
||||||
|
projects = parse_projects(args.projects or "")
|
||||||
|
if not projects:
|
||||||
|
parser.error("--projects or --project-limits is required with --backfill-redmine-projects")
|
||||||
|
print(services["backfill"].backfill_redmine_projects(projects, per_project_limit=args.per_project_limit))
|
||||||
|
return
|
||||||
|
if args.refresh_redmine_projects:
|
||||||
|
if args.project_limits:
|
||||||
|
project_limits = parse_project_limits(args.project_limits)
|
||||||
|
else:
|
||||||
|
projects = parse_projects(args.projects or "")
|
||||||
|
if not projects:
|
||||||
|
parser.error("--projects or --project-limits is required with --refresh-redmine-projects")
|
||||||
|
project_limits = {project: args.per_project_limit for project in projects}
|
||||||
|
print(
|
||||||
|
services["refresh"].refresh_redmine_project_limits(
|
||||||
|
project_limits,
|
||||||
|
dry_run=args.dry_run,
|
||||||
|
force_rebuild=args.force_rebuild,
|
||||||
|
overlap_minutes=args.overlap_minutes,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if args.mcp_stdio:
|
||||||
|
serve_stdio(SemanticMCP(search_service=services["search"], backfill_service=services["backfill"], store=services["store"], refresh_service=services.get("refresh")))
|
||||||
|
return
|
||||||
|
if args.command == "inspect":
|
||||||
|
if args.inspect_command == "count":
|
||||||
|
print_count(services["store"], args.source, args.project_identifier, args.doc_type)
|
||||||
|
return
|
||||||
|
if args.inspect_command == "list":
|
||||||
|
print_list(services["store"], args.limit, args.source, args.project_identifier, args.doc_type, args.full_text)
|
||||||
|
return
|
||||||
|
if args.inspect_command == "search":
|
||||||
|
print_search(services["search"], args.query, args.limit, args.source, args.project_identifier, args.doc_type, args.full_text)
|
||||||
|
return
|
||||||
|
if args.inspect_command == "show":
|
||||||
|
print_show(services["search"], args.document_id)
|
||||||
|
return
|
||||||
|
if args.inspect_command == "audit":
|
||||||
|
print_audit(services["store"], args.limit, args.source, args.project_identifier, args.doc_type, args.json)
|
||||||
|
return
|
||||||
|
if args.inspect_command == "compare-redmine":
|
||||||
|
project = args.project_identifier or services["settings"].redmine_project_identifier
|
||||||
|
print_compare_redmine(services["store"], services["redmine_source"], services["settings"].redmine_url, project, args.limit, args.json)
|
||||||
|
return
|
||||||
|
if args.inspect_command == "smoke-search":
|
||||||
|
project = args.project_identifier or services["settings"].redmine_project_identifier
|
||||||
|
print_smoke_search(
|
||||||
|
services["search"],
|
||||||
|
project,
|
||||||
|
args.email,
|
||||||
|
args.issue_id,
|
||||||
|
args.order_token,
|
||||||
|
args.natural_query,
|
||||||
|
args.json,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
|
from .config import Settings, load_settings
|
||||||
|
from .embeddings import OpenAIEmbedder, OpenAIEmbeddingClient
|
||||||
|
from .ingest import BackfillService
|
||||||
|
from .models import SearchQuery, search_response
|
||||||
|
from .qdrant_store import QdrantStore
|
||||||
|
from .refresh import FileRefreshState, RedmineRefreshService
|
||||||
|
from .redmine import RedmineApiSource, RedmineMapper
|
||||||
|
from .search import HybridSearchService
|
||||||
|
|
||||||
|
|
||||||
|
def build_services(settings: Optional[Settings] = None) -> Dict[str, Any]:
|
||||||
|
settings = settings or load_settings()
|
||||||
|
embedding_client = OpenAIEmbeddingClient(api_key=settings.openai_api_key)
|
||||||
|
embedder = OpenAIEmbedder(client=embedding_client)
|
||||||
|
store = QdrantStore(
|
||||||
|
url=settings.qdrant_url,
|
||||||
|
api_key=settings.qdrant_api_key,
|
||||||
|
collection=settings.qdrant_collection,
|
||||||
|
)
|
||||||
|
redmine_source = RedmineApiSource(
|
||||||
|
redmine_url=settings.redmine_url,
|
||||||
|
api_key=settings.redmine_api_key or "",
|
||||||
|
project_identifier=settings.redmine_project_identifier,
|
||||||
|
)
|
||||||
|
search_service = HybridSearchService(embedder=embedder, store=store)
|
||||||
|
backfill_service = BackfillService(
|
||||||
|
source=redmine_source,
|
||||||
|
embedder=embedder,
|
||||||
|
store=store,
|
||||||
|
mapper=RedmineMapper(redmine_url=settings.redmine_url, project_identifier=settings.redmine_project_identifier),
|
||||||
|
)
|
||||||
|
refresh_service = RedmineRefreshService(
|
||||||
|
source=redmine_source,
|
||||||
|
embedder=embedder,
|
||||||
|
store=store,
|
||||||
|
mapper=RedmineMapper(redmine_url=settings.redmine_url, project_identifier=settings.redmine_project_identifier),
|
||||||
|
state=FileRefreshState(settings.refresh_state_path),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"settings": settings,
|
||||||
|
"search": search_service,
|
||||||
|
"backfill": backfill_service,
|
||||||
|
"refresh": refresh_service,
|
||||||
|
"store": store,
|
||||||
|
"redmine_source": redmine_source,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(settings: Optional[Settings] = None, service_builder: Optional[Callable[[], Dict[str, Any]]] = None):
|
||||||
|
try:
|
||||||
|
from fastapi import FastAPI, Header, HTTPException
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("Install fastapi and uvicorn to run the HTTP service") from exc
|
||||||
|
|
||||||
|
services: Optional[Dict[str, Any]] = None
|
||||||
|
app = FastAPI(title="Redmine Semantic Index", version="0.1.0")
|
||||||
|
|
||||||
|
def get_services() -> Dict[str, Any]:
|
||||||
|
nonlocal services
|
||||||
|
if services is None:
|
||||||
|
if service_builder is not None:
|
||||||
|
services = service_builder()
|
||||||
|
else:
|
||||||
|
services = build_services(settings)
|
||||||
|
return services
|
||||||
|
|
||||||
|
def authorize(authorization: Optional[str]) -> None:
|
||||||
|
api_key = get_services()["settings"].service_api_key
|
||||||
|
if not api_key:
|
||||||
|
return
|
||||||
|
expected = f"Bearer {api_key}"
|
||||||
|
if authorization != expected:
|
||||||
|
raise HTTPException(status_code=401, detail="unauthorized")
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> Dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
@app.post("/sources/redmine/backfill-sample")
|
||||||
|
def backfill(payload: Dict[str, Any] | None = None, authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]:
|
||||||
|
authorize(authorization)
|
||||||
|
active_services = get_services()
|
||||||
|
limit = int((payload or {}).get("limit", active_services["settings"].sample_limit))
|
||||||
|
return active_services["backfill"].backfill_redmine_sample(limit=limit)
|
||||||
|
|
||||||
|
@app.post("/sources/redmine/refresh")
|
||||||
|
def refresh(payload: Dict[str, Any] | None = None, authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]:
|
||||||
|
authorize(authorization)
|
||||||
|
payload = payload or {}
|
||||||
|
project_limits = payload.get("project_limits")
|
||||||
|
if not project_limits:
|
||||||
|
project = payload.get("project_identifier") or get_services()["settings"].redmine_project_identifier
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=400, detail="project_limits or project_identifier is required")
|
||||||
|
project_limits = {project: int(payload.get("limit", get_services()["settings"].sample_limit))}
|
||||||
|
return get_services()["refresh"].refresh_redmine_project_limits(
|
||||||
|
{str(project): int(limit) for project, limit in project_limits.items()},
|
||||||
|
dry_run=bool(payload.get("dry_run", False)),
|
||||||
|
force_rebuild=bool(payload.get("force_rebuild", False)),
|
||||||
|
overlap_minutes=int(payload.get("overlap_minutes", 15)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.post("/search")
|
||||||
|
def search(payload: Dict[str, Any], authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]:
|
||||||
|
authorize(authorization)
|
||||||
|
query = SearchQuery(
|
||||||
|
text=payload.get("query") or payload.get("text") or "",
|
||||||
|
source=payload.get("source"),
|
||||||
|
project_id=payload.get("project_id"),
|
||||||
|
project_identifier=payload.get("project_identifier"),
|
||||||
|
doc_type=payload.get("doc_type"),
|
||||||
|
issue_id=payload.get("issue_id"),
|
||||||
|
contact_id=payload.get("contact_id"),
|
||||||
|
contact_email=payload.get("contact_email"),
|
||||||
|
date_from=payload.get("date_from"),
|
||||||
|
date_to=payload.get("date_to"),
|
||||||
|
limit=int(payload.get("limit", 10)),
|
||||||
|
include_snippets=bool(payload.get("include_snippets", True)),
|
||||||
|
)
|
||||||
|
results = get_services()["search"].search(query)
|
||||||
|
return search_response(query, results)
|
||||||
|
|
||||||
|
@app.get("/projects")
|
||||||
|
def projects(authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]:
|
||||||
|
authorize(authorization)
|
||||||
|
return {"projects": get_services()["store"].list_projects(source="redmine")}
|
||||||
|
|
||||||
|
@app.get("/documents/{document_id}")
|
||||||
|
def document(document_id: str, authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]:
|
||||||
|
authorize(authorization)
|
||||||
|
found = get_services()["search"].get_document(document_id)
|
||||||
|
if found is None:
|
||||||
|
raise HTTPException(status_code=404, detail="not_found")
|
||||||
|
return found
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
class LazyASGIApp:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._app = None
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if self._app is None:
|
||||||
|
self._app = create_app()
|
||||||
|
await self._app(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
|
app = LazyASGIApp()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
def chunk_text(text: str, max_chars: int = 3500, overlap: int = 300) -> List[str]:
|
||||||
|
cleaned = "\n".join(line.rstrip() for line in text.strip().splitlines()).strip()
|
||||||
|
if not cleaned:
|
||||||
|
return []
|
||||||
|
if len(cleaned) <= max_chars:
|
||||||
|
return [cleaned]
|
||||||
|
|
||||||
|
chunks: List[str] = []
|
||||||
|
start = 0
|
||||||
|
while start < len(cleaned):
|
||||||
|
end = min(start + max_chars, len(cleaned))
|
||||||
|
if end < len(cleaned):
|
||||||
|
boundary = max(cleaned.rfind("\n\n", start, end), cleaned.rfind(". ", start, end))
|
||||||
|
if boundary > start + int(max_chars * 0.5):
|
||||||
|
end = boundary + 1
|
||||||
|
chunks.append(cleaned[start:end].strip())
|
||||||
|
if end >= len(cleaned):
|
||||||
|
break
|
||||||
|
start = max(0, end - overlap)
|
||||||
|
return [chunk for chunk in chunks if chunk]
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from .app import build_services
|
||||||
|
from .models import SearchQuery, search_response
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticIndexClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
search_service: Optional[Any] = None,
|
||||||
|
) -> None:
|
||||||
|
self.base_url = base_url.rstrip("/") if base_url else None
|
||||||
|
self.api_key = api_key
|
||||||
|
self.search_service = search_service
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def local(cls) -> "SemanticIndexClient":
|
||||||
|
return cls(search_service=build_services()["search"])
|
||||||
|
|
||||||
|
def search(self, query: str, **filters: Any) -> Dict[str, Any]:
|
||||||
|
if self.base_url:
|
||||||
|
return self._post_json("/search", {"query": query, **filters})
|
||||||
|
search_service = self.search_service or build_services()["search"]
|
||||||
|
search_query = SearchQuery(
|
||||||
|
text=query,
|
||||||
|
source=filters.get("source"),
|
||||||
|
project_id=filters.get("project_id"),
|
||||||
|
project_identifier=filters.get("project_identifier"),
|
||||||
|
doc_type=filters.get("doc_type"),
|
||||||
|
issue_id=filters.get("issue_id"),
|
||||||
|
contact_id=filters.get("contact_id"),
|
||||||
|
contact_email=filters.get("contact_email"),
|
||||||
|
date_from=filters.get("date_from"),
|
||||||
|
date_to=filters.get("date_to"),
|
||||||
|
limit=int(filters.get("limit", 10)),
|
||||||
|
include_snippets=bool(filters.get("include_snippets", True)),
|
||||||
|
)
|
||||||
|
return search_response(search_query, search_service.search(search_query))
|
||||||
|
|
||||||
|
def get_document(self, document_id: str) -> Dict[str, Any]:
|
||||||
|
if self.base_url:
|
||||||
|
return self._get_json(f"/documents/{document_id}")
|
||||||
|
search_service = self.search_service or build_services()["search"]
|
||||||
|
return search_service.get_document(document_id) or {"error": "not_found", "id": document_id}
|
||||||
|
|
||||||
|
def _post_json(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"{self.base_url}{path}",
|
||||||
|
data=data,
|
||||||
|
headers=self._headers(),
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(request, timeout=60) as response:
|
||||||
|
return json.loads(response.read().decode("utf-8"))
|
||||||
|
|
||||||
|
def _get_json(self, path: str) -> Dict[str, Any]:
|
||||||
|
request = urllib.request.Request(f"{self.base_url}{path}", headers=self._headers())
|
||||||
|
with urllib.request.urlopen(request, timeout=60) as response:
|
||||||
|
return json.loads(response.read().decode("utf-8"))
|
||||||
|
|
||||||
|
def _headers(self) -> Dict[str, str]:
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if self.api_key:
|
||||||
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||||
|
return headers
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Settings:
|
||||||
|
openai_api_key: Optional[str]
|
||||||
|
qdrant_url: str
|
||||||
|
qdrant_api_key: Optional[str]
|
||||||
|
qdrant_collection: str
|
||||||
|
redmine_url: str
|
||||||
|
redmine_api_key: Optional[str]
|
||||||
|
redmine_project_identifier: Optional[str]
|
||||||
|
sample_limit: int
|
||||||
|
bind_host: str
|
||||||
|
bind_port: int
|
||||||
|
service_api_key: Optional[str]
|
||||||
|
refresh_state_path: Path
|
||||||
|
|
||||||
|
|
||||||
|
def load_dotenv(path: str | Path = ".env") -> Dict[str, str]:
|
||||||
|
values: Dict[str, str] = {}
|
||||||
|
dotenv = Path(path)
|
||||||
|
if not dotenv.exists():
|
||||||
|
return values
|
||||||
|
for raw_line in dotenv.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
values[key.strip()] = value.strip().strip('"').strip("'")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_dotenv_path(dotenv_path: str | Path = ".env") -> Path:
|
||||||
|
primary = Path(dotenv_path)
|
||||||
|
if primary.exists():
|
||||||
|
return primary
|
||||||
|
package_env = primary.parent / "semantic_index" / ".env"
|
||||||
|
if package_env.exists():
|
||||||
|
return package_env
|
||||||
|
return primary
|
||||||
|
|
||||||
|
|
||||||
|
def load_settings(dotenv_path: str | Path = ".env") -> Settings:
|
||||||
|
env = {**load_dotenv(resolve_dotenv_path(dotenv_path)), **os.environ}
|
||||||
|
return Settings(
|
||||||
|
openai_api_key=env.get("OPENAI_API_KEY"),
|
||||||
|
qdrant_url=env.get("QDRANT_URL", "http://localhost:6333"),
|
||||||
|
qdrant_api_key=env.get("QDRANT_API_KEY"),
|
||||||
|
qdrant_collection=env.get("QDRANT_COLLECTION", "redmine_semantic_sample"),
|
||||||
|
redmine_url=env.get("REDMINE_URL", "http://localhost"),
|
||||||
|
redmine_api_key=env.get("REDMINE_API_KEY"),
|
||||||
|
redmine_project_identifier=env.get("REDMINE_PROJECT_IDENTIFIER"),
|
||||||
|
sample_limit=int(env.get("REDMINE_SAMPLE_LIMIT", "500")),
|
||||||
|
bind_host=env.get("SEMANTIC_INDEX_HOST", "127.0.0.1"),
|
||||||
|
bind_port=int(env.get("SEMANTIC_INDEX_PORT", "8787")),
|
||||||
|
service_api_key=env.get("SEMANTIC_INDEX_API_KEY"),
|
||||||
|
refresh_state_path=Path(env.get("SEMANTIC_INDEX_REFRESH_STATE_PATH", ".cache/semantic_index/refresh_state.json")),
|
||||||
|
)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Iterable, List, Optional, Protocol, Sequence
|
||||||
|
|
||||||
|
from .models import IndexDocument
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingClient(Protocol):
|
||||||
|
def create_embeddings(self, model: str, inputs: Sequence[str], dimensions: Optional[int] = None) -> List[List[float]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIEmbeddingClient:
|
||||||
|
def __init__(self, api_key: Optional[str] = None) -> None:
|
||||||
|
try:
|
||||||
|
from openai import OpenAI
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("Install openai to use live embeddings") from exc
|
||||||
|
self.client = OpenAI(api_key=api_key)
|
||||||
|
|
||||||
|
def create_embeddings(self, model: str, inputs: Sequence[str], dimensions: Optional[int] = None) -> List[List[float]]:
|
||||||
|
kwargs = {"model": model, "input": list(inputs)}
|
||||||
|
if dimensions is not None:
|
||||||
|
kwargs["dimensions"] = dimensions
|
||||||
|
response = self.client.embeddings.create(**kwargs)
|
||||||
|
return [item.embedding for item in response.data]
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIEmbedder:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client: EmbeddingClient,
|
||||||
|
model: str = "text-embedding-3-small",
|
||||||
|
dimensions: int = 1536,
|
||||||
|
batch_size: int = 100,
|
||||||
|
max_chars: int = 12000,
|
||||||
|
) -> None:
|
||||||
|
self.client = client
|
||||||
|
self.model = model
|
||||||
|
self.dimensions = dimensions
|
||||||
|
self.batch_size = batch_size
|
||||||
|
self.max_chars = max_chars
|
||||||
|
|
||||||
|
def embed_documents(self, documents: Sequence[IndexDocument]) -> List[List[float]]:
|
||||||
|
return self.embed_texts([document.text for document in documents])
|
||||||
|
|
||||||
|
def embed_query(self, text: str) -> List[float]:
|
||||||
|
return self.embed_texts([text])[0]
|
||||||
|
|
||||||
|
def embed_texts(self, texts: Iterable[str]) -> List[List[float]]:
|
||||||
|
values = list(texts)
|
||||||
|
self._validate(values)
|
||||||
|
vectors: List[List[float]] = []
|
||||||
|
for start in range(0, len(values), self.batch_size):
|
||||||
|
batch = values[start : start + self.batch_size]
|
||||||
|
vectors.extend(self.client.create_embeddings(self.model, batch, dimensions=self.dimensions))
|
||||||
|
return vectors
|
||||||
|
|
||||||
|
def _validate(self, texts: Sequence[str]) -> None:
|
||||||
|
for text in texts:
|
||||||
|
if not text.strip():
|
||||||
|
raise ValueError("embedding text cannot be empty")
|
||||||
|
if len(text) > self.max_chars:
|
||||||
|
raise ValueError(f"embedding text exceeds {self.max_chars} characters")
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Iterable, List, Protocol, Sequence
|
||||||
|
|
||||||
|
from .models import IndexDocument
|
||||||
|
from .redmine import RedmineMapper
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineSource(Protocol):
|
||||||
|
project_identifier: str | None
|
||||||
|
|
||||||
|
def recent_helpdesk_issues(self, limit: int) -> Iterable[Dict[str, Any]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentEmbedder(Protocol):
|
||||||
|
def embed_documents(self, docs: Sequence[IndexDocument]) -> List[List[float]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class RebuildStore(Protocol):
|
||||||
|
def rebuild_source(
|
||||||
|
self,
|
||||||
|
source: str,
|
||||||
|
docs: Sequence[IndexDocument],
|
||||||
|
vectors: Sequence[Sequence[float]],
|
||||||
|
project_identifier: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class BackfillService:
|
||||||
|
def __init__(self, source: RedmineSource, embedder: DocumentEmbedder, store: RebuildStore, mapper: RedmineMapper | None = None) -> None:
|
||||||
|
self.source = source
|
||||||
|
self.embedder = embedder
|
||||||
|
self.store = store
|
||||||
|
self.mapper = mapper or RedmineMapper(redmine_url="")
|
||||||
|
|
||||||
|
def backfill_redmine_sample(self, limit: int = 500) -> Dict[str, int | str]:
|
||||||
|
issues = list(self.source.recent_helpdesk_issues(limit))
|
||||||
|
documents: List[IndexDocument] = []
|
||||||
|
for issue in issues:
|
||||||
|
documents.extend(self.mapper.issue_to_documents(issue))
|
||||||
|
documents = deduplicate_documents(documents)
|
||||||
|
vectors = self.embedder.embed_documents(documents) if documents else []
|
||||||
|
self.store.rebuild_source("redmine", documents, vectors, project_identifier=self._project_identifier())
|
||||||
|
return {"source": "redmine", "issues": len(issues), "documents": len(documents)}
|
||||||
|
|
||||||
|
def backfill_redmine_projects(self, projects: Sequence[str], per_project_limit: int = 500) -> Dict[str, object]:
|
||||||
|
return self.backfill_redmine_project_limits({project: per_project_limit for project in projects})
|
||||||
|
|
||||||
|
def backfill_redmine_project_limits(self, project_limits: Dict[str, int]) -> Dict[str, object]:
|
||||||
|
previous_source_project = getattr(self.source, "project_identifier", None)
|
||||||
|
previous_mapper_project = getattr(self.mapper, "project_identifier", None)
|
||||||
|
project_results: List[Dict[str, int | str]] = []
|
||||||
|
total_issues = 0
|
||||||
|
total_documents = 0
|
||||||
|
try:
|
||||||
|
for project, project_limit in project_limits.items():
|
||||||
|
if hasattr(self.source, "project_identifier"):
|
||||||
|
self.source.project_identifier = project
|
||||||
|
if hasattr(self.mapper, "project_identifier"):
|
||||||
|
self.mapper.project_identifier = project
|
||||||
|
issues = list(self.source.recent_helpdesk_issues(project_limit))
|
||||||
|
documents: List[IndexDocument] = []
|
||||||
|
for issue in issues:
|
||||||
|
documents.extend(self.mapper.issue_to_documents(issue))
|
||||||
|
documents = deduplicate_documents(documents)
|
||||||
|
vectors = self.embedder.embed_documents(documents) if documents else []
|
||||||
|
self.store.rebuild_source("redmine", documents, vectors, project_identifier=project)
|
||||||
|
project_results.append(
|
||||||
|
{"project_identifier": project, "issues": len(issues), "documents": len(documents)}
|
||||||
|
)
|
||||||
|
total_issues += len(issues)
|
||||||
|
total_documents += len(documents)
|
||||||
|
finally:
|
||||||
|
if hasattr(self.source, "project_identifier"):
|
||||||
|
self.source.project_identifier = previous_source_project
|
||||||
|
if hasattr(self.mapper, "project_identifier"):
|
||||||
|
self.mapper.project_identifier = previous_mapper_project
|
||||||
|
return {
|
||||||
|
"source": "redmine",
|
||||||
|
"projects": len(project_limits),
|
||||||
|
"issues": total_issues,
|
||||||
|
"documents": total_documents,
|
||||||
|
"project_results": project_results,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _project_identifier(self) -> str | None:
|
||||||
|
mapper_project = getattr(self.mapper, "project_identifier", None)
|
||||||
|
if mapper_project:
|
||||||
|
return mapper_project
|
||||||
|
return getattr(self.source, "project_identifier", None)
|
||||||
|
|
||||||
|
|
||||||
|
def deduplicate_documents(documents: Sequence[IndexDocument]) -> List[IndexDocument]:
|
||||||
|
unique: Dict[str, IndexDocument] = {}
|
||||||
|
for document in documents:
|
||||||
|
unique[document.id] = document
|
||||||
|
return list(unique.values())
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections import Counter
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
from .models import SearchQuery, SearchResult
|
||||||
|
from .redmine import RedmineMapper
|
||||||
|
|
||||||
|
|
||||||
|
def print_count(store: Any, source: Optional[str], project: Optional[str], doc_type: Optional[str]) -> None:
|
||||||
|
count = store.count_documents(source=source, project_identifier=project, doc_type=doc_type)
|
||||||
|
print(count)
|
||||||
|
|
||||||
|
|
||||||
|
def print_list(store: Any, limit: int, source: Optional[str], project: Optional[str], doc_type: Optional[str], full_text: bool) -> None:
|
||||||
|
documents = store.list_documents(limit=limit, source=source, project_identifier=project, doc_type=doc_type)
|
||||||
|
for document in documents:
|
||||||
|
print_document(document, full_text=full_text)
|
||||||
|
|
||||||
|
|
||||||
|
def print_search(search_service: Any, query_text: str, limit: int, source: Optional[str], project: Optional[str], doc_type: Optional[str], full_text: bool) -> None:
|
||||||
|
query = SearchQuery(
|
||||||
|
text=query_text,
|
||||||
|
source=source,
|
||||||
|
project_identifier=project,
|
||||||
|
doc_type=doc_type,
|
||||||
|
limit=limit,
|
||||||
|
include_snippets=not full_text,
|
||||||
|
)
|
||||||
|
for result in search_service.search(query):
|
||||||
|
print_result(result, full_text=full_text)
|
||||||
|
|
||||||
|
|
||||||
|
def print_show(search_service: Any, document_id: str) -> None:
|
||||||
|
document = search_service.get_document(document_id)
|
||||||
|
if document is None:
|
||||||
|
print(f"not found: {document_id}")
|
||||||
|
return
|
||||||
|
print_document(document, full_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def print_preview_redmine(source: Any, redmine_url: str, project: Optional[str], limit: int, full_text: bool) -> None:
|
||||||
|
previous_project = getattr(source, "project_identifier", None)
|
||||||
|
if project and hasattr(source, "project_identifier"):
|
||||||
|
source.project_identifier = project
|
||||||
|
try:
|
||||||
|
mapper = RedmineMapper(redmine_url=redmine_url, project_identifier=project)
|
||||||
|
documents = []
|
||||||
|
for issue in source.recent_helpdesk_issues(limit):
|
||||||
|
documents.extend(mapper.issue_to_documents(issue))
|
||||||
|
finally:
|
||||||
|
if hasattr(source, "project_identifier"):
|
||||||
|
source.project_identifier = previous_project
|
||||||
|
for document in documents:
|
||||||
|
print_document({"id": document.id, "text": document.text, "payload": document.payload}, full_text=full_text)
|
||||||
|
|
||||||
|
|
||||||
|
def print_audit(store: Any, limit: int, source: Optional[str], project: Optional[str], doc_type: Optional[str], as_json: bool) -> None:
|
||||||
|
documents = store.list_documents(limit=limit, source=source, project_identifier=project, doc_type=doc_type)
|
||||||
|
report = audit_documents(documents)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(report, sort_keys=True))
|
||||||
|
return
|
||||||
|
print(f"documents={report['total_documents']}")
|
||||||
|
for name, count in sorted(report["doc_type_counts"].items()):
|
||||||
|
print(f"doc_type {name}={count}")
|
||||||
|
for name, count in sorted(report["project_counts"].items()):
|
||||||
|
print(f"project {name}={count}")
|
||||||
|
print(f"contact_metadata {report['contact_metadata_count']}/{report['total_documents']}")
|
||||||
|
print(f"helpdesk_contact_metadata {report['helpdesk_contact_metadata_count']}/{report['helpdesk_documents']}")
|
||||||
|
print(f"attachments={report['attachment_documents']}")
|
||||||
|
for document_id in report["missing_helpdesk_contact_metadata"]:
|
||||||
|
print(f"missing_contact {document_id}")
|
||||||
|
for document_id in report["unexpected_attachment_documents"]:
|
||||||
|
print(f"unexpected_attachment {document_id}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_compare_redmine(store: Any, source: Any, redmine_url: str, project: Optional[str], limit: int, as_json: bool) -> None:
|
||||||
|
preview_documents = preview_redmine_documents(source, redmine_url, project, limit)
|
||||||
|
indexed_documents = store.list_documents(limit=max(5000, limit * 100), source="redmine", project_identifier=project)
|
||||||
|
report = compare_documents(preview_documents, indexed_documents)
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(report, sort_keys=True))
|
||||||
|
return
|
||||||
|
print(f"preview_documents={report['preview_documents']}")
|
||||||
|
print(f"indexed_documents={report['indexed_documents']}")
|
||||||
|
for document_id in report["missing"]:
|
||||||
|
print(f"missing {document_id}")
|
||||||
|
for document_id in report["stale"]:
|
||||||
|
print(f"stale {document_id}")
|
||||||
|
for mismatch in report["contact_mismatches"]:
|
||||||
|
print(f"contact_mismatch {mismatch['id']}")
|
||||||
|
|
||||||
|
|
||||||
|
def print_smoke_search(
|
||||||
|
search_service: Any,
|
||||||
|
project: Optional[str],
|
||||||
|
email: str,
|
||||||
|
issue_id: Optional[int],
|
||||||
|
order_token: Optional[str],
|
||||||
|
natural_query: str,
|
||||||
|
as_json: bool,
|
||||||
|
) -> None:
|
||||||
|
checks = smoke_search(search_service, project, email, issue_id, order_token, natural_query)
|
||||||
|
report = {"project_identifier": project, "checks": checks}
|
||||||
|
if as_json:
|
||||||
|
print(json.dumps(report, sort_keys=True))
|
||||||
|
return
|
||||||
|
for check in checks:
|
||||||
|
status = "PASS" if check["passed"] else "FAIL"
|
||||||
|
print(f"{status} {check['kind']} {check['query']}")
|
||||||
|
for result in check["results"]:
|
||||||
|
payload = result["payload"]
|
||||||
|
print(
|
||||||
|
f" {result['id']} score={result['score']:.4f} "
|
||||||
|
f"doc_type={payload.get('doc_type')} issue={payload.get('issue_id')} "
|
||||||
|
f"contact={contact_display(payload)} url={result['citation'].get('url')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def audit_documents(documents: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
doc_type_counts = Counter(str((document.get("payload") or {}).get("doc_type") or "unknown") for document in documents)
|
||||||
|
project_counts = Counter(str((document.get("payload") or {}).get("project_identifier") or "unknown") for document in documents)
|
||||||
|
missing_contact = []
|
||||||
|
missing_helpdesk_contact = []
|
||||||
|
contact_metadata_count = 0
|
||||||
|
helpdesk_documents = 0
|
||||||
|
helpdesk_contact_metadata_count = 0
|
||||||
|
unexpected_attachments = []
|
||||||
|
for document in documents:
|
||||||
|
payload = document.get("payload") or {}
|
||||||
|
doc_type = str(payload.get("doc_type") or "")
|
||||||
|
has_contact = bool(payload.get("contact_id") and payload.get("contact_email"))
|
||||||
|
has_helpdesk_ticket = bool(payload.get("has_helpdesk_ticket"))
|
||||||
|
if has_contact:
|
||||||
|
contact_metadata_count += 1
|
||||||
|
elif doc_type in {"issue", "journal", "message", "contact"} and has_helpdesk_ticket:
|
||||||
|
missing_contact.append(str(document.get("id")))
|
||||||
|
if has_helpdesk_ticket:
|
||||||
|
helpdesk_documents += 1
|
||||||
|
if has_contact:
|
||||||
|
helpdesk_contact_metadata_count += 1
|
||||||
|
elif doc_type in {"issue", "journal", "message", "contact"}:
|
||||||
|
missing_helpdesk_contact.append(str(document.get("id")))
|
||||||
|
if doc_type == "attachment":
|
||||||
|
unexpected_attachments.append(str(document.get("id")))
|
||||||
|
return {
|
||||||
|
"total_documents": len(documents),
|
||||||
|
"doc_type_counts": dict(doc_type_counts),
|
||||||
|
"project_counts": dict(project_counts),
|
||||||
|
"contact_metadata_count": contact_metadata_count,
|
||||||
|
"helpdesk_documents": helpdesk_documents,
|
||||||
|
"helpdesk_contact_metadata_count": helpdesk_contact_metadata_count,
|
||||||
|
"missing_contact_metadata": missing_contact,
|
||||||
|
"missing_helpdesk_contact_metadata": missing_helpdesk_contact,
|
||||||
|
"attachment_documents": len(unexpected_attachments),
|
||||||
|
"unexpected_attachment_documents": unexpected_attachments,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def preview_redmine_documents(source: Any, redmine_url: str, project: Optional[str], limit: int) -> List[Dict[str, Any]]:
|
||||||
|
previous_project = getattr(source, "project_identifier", None)
|
||||||
|
if project and hasattr(source, "project_identifier"):
|
||||||
|
source.project_identifier = project
|
||||||
|
try:
|
||||||
|
mapper = RedmineMapper(redmine_url=redmine_url, project_identifier=project)
|
||||||
|
documents = []
|
||||||
|
for issue in source.recent_helpdesk_issues(limit):
|
||||||
|
documents.extend(mapper.issue_to_documents(issue))
|
||||||
|
return [{"id": document.id, "text": document.text, "payload": document.payload} for document in documents]
|
||||||
|
finally:
|
||||||
|
if hasattr(source, "project_identifier"):
|
||||||
|
source.project_identifier = previous_project
|
||||||
|
|
||||||
|
|
||||||
|
def compare_documents(preview_documents: List[Dict[str, Any]], indexed_documents: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
indexed_by_id = {str(document.get("id")): document for document in indexed_documents}
|
||||||
|
missing = []
|
||||||
|
stale = []
|
||||||
|
contact_mismatches = []
|
||||||
|
for preview in preview_documents:
|
||||||
|
document_id = str(preview.get("id"))
|
||||||
|
indexed = indexed_by_id.get(document_id)
|
||||||
|
if indexed is None:
|
||||||
|
missing.append(document_id)
|
||||||
|
continue
|
||||||
|
preview_payload = preview.get("payload") or {}
|
||||||
|
indexed_payload = indexed.get("payload") or {}
|
||||||
|
if preview_payload.get("source_hash") != indexed_payload.get("source_hash"):
|
||||||
|
stale.append(document_id)
|
||||||
|
contact_fields = ("contact_id", "contact_name", "contact_email", "contact_company")
|
||||||
|
if any(preview_payload.get(field) != indexed_payload.get(field) for field in contact_fields):
|
||||||
|
contact_mismatches.append({"id": document_id})
|
||||||
|
return {
|
||||||
|
"preview_documents": len(preview_documents),
|
||||||
|
"indexed_documents": len(indexed_documents),
|
||||||
|
"missing": missing,
|
||||||
|
"stale": stale,
|
||||||
|
"contact_mismatches": contact_mismatches,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def smoke_search(
|
||||||
|
search_service: Any,
|
||||||
|
project: Optional[str],
|
||||||
|
email: str,
|
||||||
|
issue_id: Optional[int],
|
||||||
|
order_token: Optional[str],
|
||||||
|
natural_query: str,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
checks = [run_smoke_query(search_service, "email", email, project, expected_email=email)]
|
||||||
|
if issue_id is not None:
|
||||||
|
checks.append(run_smoke_query(search_service, "issue", str(issue_id), project, expected_issue_id=issue_id))
|
||||||
|
if order_token:
|
||||||
|
checks.append(run_smoke_query(search_service, "order", order_token, project))
|
||||||
|
if natural_query:
|
||||||
|
checks.append(run_smoke_query(search_service, "natural", natural_query, project))
|
||||||
|
return checks
|
||||||
|
|
||||||
|
|
||||||
|
def run_smoke_query(
|
||||||
|
search_service: Any,
|
||||||
|
kind: str,
|
||||||
|
text: str,
|
||||||
|
project: Optional[str],
|
||||||
|
expected_email: Optional[str] = None,
|
||||||
|
expected_issue_id: Optional[int] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
query = SearchQuery(text=text, source="redmine", project_identifier=project, issue_id=expected_issue_id, limit=5)
|
||||||
|
results = search_service.search(query)
|
||||||
|
result_dicts = [result.to_dict(include_snippet=True) for result in results]
|
||||||
|
passed = bool(result_dicts)
|
||||||
|
if expected_email:
|
||||||
|
passed = passed and any((result["payload"] or {}).get("contact_email") == expected_email for result in result_dicts)
|
||||||
|
if expected_issue_id is not None:
|
||||||
|
passed = passed and any((result["payload"] or {}).get("issue_id") == expected_issue_id for result in result_dicts)
|
||||||
|
return {"kind": kind, "query": text, "passed": passed, "results": result_dicts}
|
||||||
|
|
||||||
|
|
||||||
|
def print_result(result: SearchResult, full_text: bool) -> None:
|
||||||
|
print(f"{result.id} score={result.score:.4f}")
|
||||||
|
print_metadata(result.payload)
|
||||||
|
print(f"url={result.citation.get('url')}")
|
||||||
|
print(result.text if full_text else snippet(result.text))
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def print_document(document: Dict[str, Any], full_text: bool) -> None:
|
||||||
|
payload = document.get("payload") or {}
|
||||||
|
print(document.get("id"))
|
||||||
|
print_metadata(payload)
|
||||||
|
url = payload.get("redmine_url")
|
||||||
|
if url:
|
||||||
|
print(f"url={url}")
|
||||||
|
print(document.get("text", "") if full_text else snippet(document.get("text", "")))
|
||||||
|
print()
|
||||||
|
|
||||||
|
|
||||||
|
def print_metadata(payload: Dict[str, Any]) -> None:
|
||||||
|
contact = contact_display(payload)
|
||||||
|
fields = [
|
||||||
|
("source", payload.get("source")),
|
||||||
|
("doc_type", payload.get("doc_type")),
|
||||||
|
("issue", payload.get("issue_id")),
|
||||||
|
("project", payload.get("project_identifier")),
|
||||||
|
("contact", contact),
|
||||||
|
("created", payload.get("created_on")),
|
||||||
|
("updated", payload.get("updated_on")),
|
||||||
|
]
|
||||||
|
print(" ".join(f"{name}={value}" for name, value in fields if value is not None))
|
||||||
|
|
||||||
|
|
||||||
|
def contact_display(payload: Dict[str, Any]) -> Optional[str]:
|
||||||
|
contact_id = payload.get("contact_id")
|
||||||
|
pieces = []
|
||||||
|
if contact_id is not None:
|
||||||
|
pieces.append(f"#{contact_id}")
|
||||||
|
if payload.get("contact_name"):
|
||||||
|
pieces.append(str(payload["contact_name"]))
|
||||||
|
if payload.get("contact_email"):
|
||||||
|
pieces.append(str(payload["contact_email"]))
|
||||||
|
if payload.get("contact_company"):
|
||||||
|
pieces.append(str(payload["contact_company"]))
|
||||||
|
return " | ".join(pieces) if pieces else None
|
||||||
|
|
||||||
|
|
||||||
|
def snippet(text: str, max_chars: int = 240) -> str:
|
||||||
|
compact = " ".join(text.split())
|
||||||
|
if len(compact) <= max_chars:
|
||||||
|
return compact
|
||||||
|
return compact[: max_chars - 3].rstrip() + "..."
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from .models import SearchQuery, search_response
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticMCP:
|
||||||
|
def __init__(self, search_service: Any, backfill_service: Optional[Any], store: Optional[Any] = None, refresh_service: Optional[Any] = None) -> None:
|
||||||
|
self.search_service = search_service
|
||||||
|
self.backfill_service = backfill_service
|
||||||
|
self.store = store
|
||||||
|
self.refresh_service = refresh_service
|
||||||
|
|
||||||
|
def tools(self) -> Dict[str, Dict[str, str]]:
|
||||||
|
return {
|
||||||
|
"semantic_search": {"description": "Search the semantic index and return cited snippets."},
|
||||||
|
"semantic_get_document": {"description": "Fetch one indexed document by stable id."},
|
||||||
|
"semantic_list_projects": {"description": "List indexed project identifiers and document counts."},
|
||||||
|
"semantic_backfill_redmine_sample": {"description": "Rebuild the Redmine sample collection."},
|
||||||
|
"semantic_refresh_redmine": {"description": "Refresh recent Redmine issues without re-embedding unchanged documents."},
|
||||||
|
}
|
||||||
|
|
||||||
|
def call_tool(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
if name == "semantic_search":
|
||||||
|
query = SearchQuery(
|
||||||
|
text=arguments.get("query") or arguments.get("text") or "",
|
||||||
|
source=arguments.get("source"),
|
||||||
|
project_id=arguments.get("project_id"),
|
||||||
|
project_identifier=arguments.get("project_identifier"),
|
||||||
|
doc_type=arguments.get("doc_type"),
|
||||||
|
issue_id=arguments.get("issue_id"),
|
||||||
|
contact_id=arguments.get("contact_id"),
|
||||||
|
contact_email=arguments.get("contact_email"),
|
||||||
|
date_from=arguments.get("date_from"),
|
||||||
|
date_to=arguments.get("date_to"),
|
||||||
|
limit=int(arguments.get("limit", 10)),
|
||||||
|
include_snippets=bool(arguments.get("include_snippets", True)),
|
||||||
|
)
|
||||||
|
results = self.search_service.search(query)
|
||||||
|
return search_response(query, results)
|
||||||
|
if name == "semantic_get_document":
|
||||||
|
return self.search_service.get_document(arguments["id"]) or {"error": "not_found", "id": arguments["id"]}
|
||||||
|
if name == "semantic_list_projects":
|
||||||
|
if self.store is None:
|
||||||
|
return {"error": "project_listing_unavailable"}
|
||||||
|
return {"projects": self.store.list_projects(source=arguments.get("source", "redmine"))}
|
||||||
|
if name == "semantic_backfill_redmine_sample":
|
||||||
|
if self.backfill_service is None:
|
||||||
|
return {"error": "backfill_unavailable"}
|
||||||
|
return self.backfill_service.backfill_redmine_sample(limit=int(arguments.get("limit", 500)))
|
||||||
|
if name == "semantic_refresh_redmine":
|
||||||
|
if self.refresh_service is None:
|
||||||
|
return {"error": "refresh_unavailable"}
|
||||||
|
project_limits = arguments.get("project_limits")
|
||||||
|
if not project_limits:
|
||||||
|
project = arguments.get("project_identifier")
|
||||||
|
if not project:
|
||||||
|
return {"error": "project_required"}
|
||||||
|
project_limits = {project: int(arguments.get("limit", 500))}
|
||||||
|
return self.refresh_service.refresh_redmine_project_limits(
|
||||||
|
{str(project): int(limit) for project, limit in project_limits.items()},
|
||||||
|
dry_run=bool(arguments.get("dry_run", False)),
|
||||||
|
force_rebuild=bool(arguments.get("force_rebuild", False)),
|
||||||
|
overlap_minutes=int(arguments.get("overlap_minutes", 15)),
|
||||||
|
)
|
||||||
|
raise ValueError(f"unknown tool: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def serve_stdio(mcp: SemanticMCP) -> None:
|
||||||
|
for line in sys.stdin:
|
||||||
|
request = json.loads(line)
|
||||||
|
try:
|
||||||
|
result = mcp.call_tool(request["name"], request.get("arguments") or {})
|
||||||
|
response = {"id": request.get("id"), "result": result}
|
||||||
|
except Exception as exc:
|
||||||
|
response = {"id": request.get("id"), "error": str(exc)}
|
||||||
|
print(json.dumps(response), flush=True)
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
Payload = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IndexDocument:
|
||||||
|
id: str
|
||||||
|
text: str
|
||||||
|
payload: Payload = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.id.strip():
|
||||||
|
raise ValueError("document id is required")
|
||||||
|
if not self.text.strip():
|
||||||
|
raise ValueError("document text is required")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SearchQuery:
|
||||||
|
text: str
|
||||||
|
source: Optional[str] = None
|
||||||
|
project_id: Optional[int] = None
|
||||||
|
project_identifier: Optional[str] = None
|
||||||
|
doc_type: Optional[str] = None
|
||||||
|
issue_id: Optional[int] = None
|
||||||
|
contact_id: Optional[int] = None
|
||||||
|
contact_email: Optional[str] = None
|
||||||
|
date_from: Optional[str] = None
|
||||||
|
date_to: Optional[str] = None
|
||||||
|
limit: int = 10
|
||||||
|
include_snippets: bool = True
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if not self.text.strip():
|
||||||
|
raise ValueError("search text is required")
|
||||||
|
if self.limit < 1 or self.limit > 100:
|
||||||
|
raise ValueError("limit must be between 1 and 100")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SearchResult:
|
||||||
|
id: str
|
||||||
|
score: float
|
||||||
|
text: str
|
||||||
|
payload: Payload
|
||||||
|
|
||||||
|
@property
|
||||||
|
def snippet(self) -> str:
|
||||||
|
return self.text[:500]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def citation(self) -> Payload:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"source": self.payload.get("source"),
|
||||||
|
"doc_type": self.payload.get("doc_type"),
|
||||||
|
"issue_id": self.payload.get("issue_id"),
|
||||||
|
"project_identifier": self.payload.get("project_identifier"),
|
||||||
|
"contact_id": self.payload.get("contact_id"),
|
||||||
|
"contact_name": self.payload.get("contact_name"),
|
||||||
|
"contact_email": self.payload.get("contact_email"),
|
||||||
|
"url": self.payload.get("redmine_url"),
|
||||||
|
"record_id": self.payload.get("source_record_id"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_dict(self, include_snippet: bool = True) -> Payload:
|
||||||
|
data: Payload = {
|
||||||
|
"id": self.id,
|
||||||
|
"score": self.score,
|
||||||
|
"payload": self.payload,
|
||||||
|
"citation": self.citation,
|
||||||
|
}
|
||||||
|
if include_snippet:
|
||||||
|
data["snippet"] = self.snippet
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def search_response(query: SearchQuery, results: list[SearchResult]) -> Payload:
|
||||||
|
filters = {
|
||||||
|
"source": query.source,
|
||||||
|
"project_id": query.project_id,
|
||||||
|
"project_identifier": query.project_identifier,
|
||||||
|
"doc_type": query.doc_type,
|
||||||
|
"issue_id": query.issue_id,
|
||||||
|
"contact_id": query.contact_id,
|
||||||
|
"contact_email": query.contact_email,
|
||||||
|
"date_from": query.date_from,
|
||||||
|
"date_to": query.date_to,
|
||||||
|
"limit": query.limit,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"query": query.text,
|
||||||
|
"filters": {key: value for key, value in filters.items() if value is not None},
|
||||||
|
"results": [result.to_dict(include_snippet=query.include_snippets) for result in results],
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from .models import IndexDocument, SearchQuery, SearchResult
|
||||||
|
|
||||||
|
|
||||||
|
def point_id_for_document(document_id: str) -> str:
|
||||||
|
return str(uuid.uuid5(uuid.NAMESPACE_URL, document_id))
|
||||||
|
|
||||||
|
|
||||||
|
def build_filter(query: SearchQuery) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
|
must: List[Dict[str, Any]] = []
|
||||||
|
equality_fields = {
|
||||||
|
"source": query.source,
|
||||||
|
"project_id": query.project_id,
|
||||||
|
"project_identifier": query.project_identifier,
|
||||||
|
"doc_type": query.doc_type,
|
||||||
|
"issue_id": query.issue_id,
|
||||||
|
"contact_id": query.contact_id,
|
||||||
|
"contact_email": query.contact_email,
|
||||||
|
}
|
||||||
|
for key, value in equality_fields.items():
|
||||||
|
if value is not None:
|
||||||
|
must.append({"key": key, "match": {"value": value}})
|
||||||
|
if query.date_from or query.date_to:
|
||||||
|
range_filter: Dict[str, str] = {}
|
||||||
|
if query.date_from:
|
||||||
|
range_filter["gte"] = query.date_from
|
||||||
|
if query.date_to:
|
||||||
|
range_filter["lte"] = query.date_to
|
||||||
|
must.append({"key": "created_on", "range": range_filter})
|
||||||
|
return {"must": must}
|
||||||
|
|
||||||
|
|
||||||
|
class QdrantStore:
|
||||||
|
def __init__(self, url: str, api_key: Optional[str], collection: str, vector_size: int = 1536, upsert_batch_size: int = 64) -> None:
|
||||||
|
try:
|
||||||
|
from qdrant_client import QdrantClient
|
||||||
|
from qdrant_client.http import models as qmodels
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError("Install qdrant-client to use live Qdrant storage") from exc
|
||||||
|
self.client = QdrantClient(url=url, api_key=api_key)
|
||||||
|
self.collection = collection
|
||||||
|
self.vector_size = vector_size
|
||||||
|
self.upsert_batch_size = upsert_batch_size
|
||||||
|
self.qmodels = qmodels
|
||||||
|
|
||||||
|
def ensure_collection(self) -> None:
|
||||||
|
collections = self.client.get_collections().collections
|
||||||
|
if any(collection.name == self.collection for collection in collections):
|
||||||
|
return
|
||||||
|
self.client.create_collection(
|
||||||
|
collection_name=self.collection,
|
||||||
|
vectors_config=self.qmodels.VectorParams(size=self.vector_size, distance=self.qmodels.Distance.COSINE),
|
||||||
|
)
|
||||||
|
|
||||||
|
def upsert(self, documents: Sequence[IndexDocument], vectors: Sequence[Sequence[float]]) -> None:
|
||||||
|
if len(documents) != len(vectors):
|
||||||
|
raise ValueError("documents and vectors length mismatch")
|
||||||
|
self.ensure_collection()
|
||||||
|
points = [
|
||||||
|
self.qmodels.PointStruct(
|
||||||
|
id=point_id_for_document(document.id),
|
||||||
|
vector=list(vector),
|
||||||
|
payload={**document.payload, "document_id": document.id, "text": document.text},
|
||||||
|
)
|
||||||
|
for document, vector in zip(documents, vectors)
|
||||||
|
]
|
||||||
|
for start in range(0, len(points), self.upsert_batch_size):
|
||||||
|
batch = points[start : start + self.upsert_batch_size]
|
||||||
|
if batch:
|
||||||
|
self.client.upsert(collection_name=self.collection, points=batch)
|
||||||
|
|
||||||
|
def delete_by_source(self, source: str, project_identifier: Optional[str] = None) -> None:
|
||||||
|
self.ensure_collection()
|
||||||
|
query = SearchQuery(text="*", source=source, project_identifier=project_identifier)
|
||||||
|
self.client.delete(
|
||||||
|
collection_name=self.collection,
|
||||||
|
points_selector=self.qmodels.FilterSelector(
|
||||||
|
filter=self._to_qdrant_filter(build_filter(query))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_documents(self, document_ids: Sequence[str]) -> None:
|
||||||
|
self.ensure_collection()
|
||||||
|
if not document_ids:
|
||||||
|
return
|
||||||
|
self.client.delete(
|
||||||
|
collection_name=self.collection,
|
||||||
|
points_selector=self.qmodels.PointIdsList(
|
||||||
|
points=[point_id_for_document(document_id) for document_id in document_ids]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def rebuild_source(
|
||||||
|
self,
|
||||||
|
source: str,
|
||||||
|
documents: Sequence[IndexDocument],
|
||||||
|
vectors: Sequence[Sequence[float]],
|
||||||
|
project_identifier: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
self.delete_by_source(source, project_identifier=project_identifier)
|
||||||
|
self.upsert(documents, vectors)
|
||||||
|
|
||||||
|
def search(self, vector: Sequence[float], query: SearchQuery, limit: int) -> List[SearchResult]:
|
||||||
|
self.ensure_collection()
|
||||||
|
qfilter = self._to_qdrant_filter(build_filter(query))
|
||||||
|
if hasattr(self.client, "query_points"):
|
||||||
|
response = self.client.query_points(
|
||||||
|
collection_name=self.collection,
|
||||||
|
query=list(vector),
|
||||||
|
query_filter=qfilter,
|
||||||
|
limit=limit,
|
||||||
|
with_payload=True,
|
||||||
|
)
|
||||||
|
results = response.points
|
||||||
|
else:
|
||||||
|
results = self.client.search(
|
||||||
|
collection_name=self.collection,
|
||||||
|
query_vector=list(vector),
|
||||||
|
query_filter=qfilter,
|
||||||
|
limit=limit,
|
||||||
|
with_payload=True,
|
||||||
|
)
|
||||||
|
return [self._point_to_result(point) for point in results]
|
||||||
|
|
||||||
|
def get_document(self, document_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
self.ensure_collection()
|
||||||
|
points = self.client.retrieve(collection_name=self.collection, ids=[point_id_for_document(document_id)], with_payload=True)
|
||||||
|
if not points:
|
||||||
|
return None
|
||||||
|
payload = dict(points[0].payload or {})
|
||||||
|
text = payload.pop("text", "")
|
||||||
|
payload.pop("document_id", None)
|
||||||
|
return {"id": document_id, "text": text, "payload": payload}
|
||||||
|
|
||||||
|
def count_documents(
|
||||||
|
self,
|
||||||
|
source: Optional[str] = None,
|
||||||
|
project_identifier: Optional[str] = None,
|
||||||
|
doc_type: Optional[str] = None,
|
||||||
|
) -> int:
|
||||||
|
self.ensure_collection()
|
||||||
|
query = SearchQuery(text="*", source=source, project_identifier=project_identifier, doc_type=doc_type)
|
||||||
|
result = self.client.count(
|
||||||
|
collection_name=self.collection,
|
||||||
|
count_filter=self._to_qdrant_filter(build_filter(query)),
|
||||||
|
exact=True,
|
||||||
|
)
|
||||||
|
return int(result.count)
|
||||||
|
|
||||||
|
def list_documents(
|
||||||
|
self,
|
||||||
|
limit: int = 10,
|
||||||
|
source: Optional[str] = None,
|
||||||
|
project_identifier: Optional[str] = None,
|
||||||
|
doc_type: Optional[str] = None,
|
||||||
|
issue_id: Optional[int] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
self.ensure_collection()
|
||||||
|
query = SearchQuery(text="*", source=source, project_identifier=project_identifier, doc_type=doc_type, issue_id=issue_id)
|
||||||
|
qfilter = self._to_qdrant_filter(build_filter(query))
|
||||||
|
records = []
|
||||||
|
offset = None
|
||||||
|
while len(records) < limit:
|
||||||
|
batch_limit = limit - len(records)
|
||||||
|
batch, offset = self.client.scroll(
|
||||||
|
collection_name=self.collection,
|
||||||
|
scroll_filter=qfilter,
|
||||||
|
limit=batch_limit,
|
||||||
|
with_payload=True,
|
||||||
|
with_vectors=False,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
records.extend(batch[:batch_limit])
|
||||||
|
if not offset or not batch:
|
||||||
|
break
|
||||||
|
return [self._record_to_document(record) for record in records]
|
||||||
|
|
||||||
|
def list_projects(self, source: Optional[str] = None, limit: int = 5000) -> List[Dict[str, Any]]:
|
||||||
|
documents = self.list_documents(limit=limit, source=source)
|
||||||
|
counts = Counter(
|
||||||
|
str((document.get("payload") or {}).get("project_identifier"))
|
||||||
|
for document in documents
|
||||||
|
if (document.get("payload") or {}).get("project_identifier")
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{"project_identifier": project, "document_count": count}
|
||||||
|
for project, count in sorted(counts.items())
|
||||||
|
]
|
||||||
|
|
||||||
|
def _to_qdrant_filter(self, raw_filter: Dict[str, List[Dict[str, Any]]]) -> Any:
|
||||||
|
conditions = []
|
||||||
|
for condition in raw_filter.get("must", []):
|
||||||
|
if "match" in condition:
|
||||||
|
conditions.append(
|
||||||
|
self.qmodels.FieldCondition(
|
||||||
|
key=condition["key"],
|
||||||
|
match=self.qmodels.MatchValue(value=condition["match"]["value"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif "range" in condition:
|
||||||
|
conditions.append(self.qmodels.FieldCondition(key=condition["key"], range=self.qmodels.DatetimeRange(**condition["range"])))
|
||||||
|
return self.qmodels.Filter(must=conditions) if conditions else None
|
||||||
|
|
||||||
|
def _point_to_result(self, point: Any) -> SearchResult:
|
||||||
|
payload = dict(point.payload or {})
|
||||||
|
text = payload.pop("text", "")
|
||||||
|
document_id = payload.pop("document_id", str(point.id))
|
||||||
|
return SearchResult(id=document_id, score=float(point.score), text=text, payload=payload)
|
||||||
|
|
||||||
|
def _record_to_document(self, record: Any) -> Dict[str, Any]:
|
||||||
|
payload = dict(record.payload or {})
|
||||||
|
text = payload.pop("text", "")
|
||||||
|
document_id = payload.pop("document_id", str(record.id))
|
||||||
|
return {"id": document_id, "text": text, "payload": payload}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional
|
||||||
|
|
||||||
|
from .chunking import chunk_text
|
||||||
|
from .models import IndexDocument, Payload
|
||||||
|
|
||||||
|
|
||||||
|
Issue = Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineMapper:
|
||||||
|
def __init__(self, redmine_url: str, chunk_chars: int = 3500, project_identifier: Optional[str] = None) -> None:
|
||||||
|
self.redmine_url = redmine_url.rstrip("/")
|
||||||
|
self.chunk_chars = chunk_chars
|
||||||
|
self.project_identifier = project_identifier
|
||||||
|
|
||||||
|
def issue_to_documents(self, issue: Issue) -> List[IndexDocument]:
|
||||||
|
docs: List[IndexDocument] = []
|
||||||
|
docs.extend(self._issue_documents(issue))
|
||||||
|
docs.extend(self._journal_documents(issue))
|
||||||
|
docs.extend(self._message_documents(issue))
|
||||||
|
docs.extend(self._contact_documents(issue))
|
||||||
|
return docs
|
||||||
|
|
||||||
|
def _issue_documents(self, issue: Issue) -> List[IndexDocument]:
|
||||||
|
issue_id = int(issue["id"])
|
||||||
|
subject = issue.get("subject") or ""
|
||||||
|
description = issue.get("description") or ""
|
||||||
|
contact = self._issue_contact(issue)
|
||||||
|
contact_text = self._contact_text(contact)
|
||||||
|
text = f"Issue #{issue_id}: {subject}\n\n{description}\n\n{contact_text}".strip()
|
||||||
|
return self._documents_for_record(
|
||||||
|
base_id=f"redmine:issue:{issue_id}",
|
||||||
|
text=text,
|
||||||
|
issue=issue,
|
||||||
|
doc_type="issue",
|
||||||
|
source_record_id=f"issue:{issue_id}",
|
||||||
|
record=issue,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _journal_documents(self, issue: Issue) -> List[IndexDocument]:
|
||||||
|
docs: List[IndexDocument] = []
|
||||||
|
issue_id = int(issue["id"])
|
||||||
|
for journal in issue.get("journals") or []:
|
||||||
|
notes = journal.get("notes") or ""
|
||||||
|
if not notes.strip():
|
||||||
|
continue
|
||||||
|
docs.extend(
|
||||||
|
self._documents_for_record(
|
||||||
|
base_id=f"redmine:issue:{issue_id}:journal:{journal['id']}",
|
||||||
|
text=notes,
|
||||||
|
issue=issue,
|
||||||
|
doc_type="journal",
|
||||||
|
source_record_id=f"journal:{journal['id']}",
|
||||||
|
record=journal,
|
||||||
|
extra={
|
||||||
|
"journal_id": journal.get("id"),
|
||||||
|
"visibility": "private" if journal.get("private_notes") else "public",
|
||||||
|
"created_on": journal.get("created_on") or issue.get("updated_on"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return docs
|
||||||
|
|
||||||
|
def _message_documents(self, issue: Issue) -> List[IndexDocument]:
|
||||||
|
docs: List[IndexDocument] = []
|
||||||
|
issue_id = int(issue["id"])
|
||||||
|
for message in issue.get("messages") or issue.get("journal_messages") or []:
|
||||||
|
body = message.get("body") or message.get("content") or message.get("message") or ""
|
||||||
|
if not body.strip():
|
||||||
|
continue
|
||||||
|
docs.extend(
|
||||||
|
self._documents_for_record(
|
||||||
|
base_id=f"redmine:issue:{issue_id}:message:{message['id']}",
|
||||||
|
text=body,
|
||||||
|
issue=issue,
|
||||||
|
doc_type="message",
|
||||||
|
source_record_id=f"message:{message['id']}",
|
||||||
|
record=message,
|
||||||
|
extra={
|
||||||
|
"message_id": message.get("id"),
|
||||||
|
"direction": message.get("direction"),
|
||||||
|
"created_on": message.get("created_on") or issue.get("updated_on"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return docs
|
||||||
|
|
||||||
|
def _contact_documents(self, issue: Issue) -> List[IndexDocument]:
|
||||||
|
contact = self._issue_contact(issue)
|
||||||
|
contact_id = contact.get("id")
|
||||||
|
if not contact_id:
|
||||||
|
return []
|
||||||
|
text = self._contact_text(contact)
|
||||||
|
if not text.strip():
|
||||||
|
return []
|
||||||
|
return self._documents_for_record(
|
||||||
|
base_id=f"redmine:contact:{contact_id}:issue:{issue['id']}",
|
||||||
|
text=text,
|
||||||
|
issue=issue,
|
||||||
|
doc_type="contact",
|
||||||
|
source_record_id=f"contact:{contact_id}",
|
||||||
|
record=contact,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _documents_for_record(
|
||||||
|
self,
|
||||||
|
base_id: str,
|
||||||
|
text: str,
|
||||||
|
issue: Issue,
|
||||||
|
doc_type: str,
|
||||||
|
source_record_id: str,
|
||||||
|
record: Dict[str, Any],
|
||||||
|
extra: Optional[Payload] = None,
|
||||||
|
) -> List[IndexDocument]:
|
||||||
|
chunks = chunk_text(text, max_chars=self.chunk_chars)
|
||||||
|
payload = self._base_payload(issue, doc_type, source_record_id, record)
|
||||||
|
if extra:
|
||||||
|
payload.update({key: value for key, value in extra.items() if value is not None})
|
||||||
|
return [
|
||||||
|
IndexDocument(id=f"{base_id}:chunk:{index}", text=chunk, payload={**payload, "chunk_index": index})
|
||||||
|
for index, chunk in enumerate(chunks)
|
||||||
|
]
|
||||||
|
|
||||||
|
def _base_payload(self, issue: Issue, doc_type: str, source_record_id: str, record: Dict[str, Any]) -> Payload:
|
||||||
|
project = issue.get("project") or {}
|
||||||
|
helpdesk_ticket = issue.get("helpdesk_ticket") or {}
|
||||||
|
contact = self._issue_contact(issue)
|
||||||
|
issue_id = int(issue["id"])
|
||||||
|
redmine_url = issue.get("url") or f"{self.redmine_url}/issues/{issue_id}"
|
||||||
|
created_on = record.get("created_on") or issue.get("created_on")
|
||||||
|
updated_on = record.get("updated_on") or issue.get("updated_on")
|
||||||
|
return {
|
||||||
|
"source": "redmine",
|
||||||
|
"doc_type": doc_type,
|
||||||
|
"issue_id": issue_id,
|
||||||
|
"project_id": project.get("id"),
|
||||||
|
"project_identifier": project.get("identifier") or self.project_identifier,
|
||||||
|
"project_name": project.get("name"),
|
||||||
|
"has_helpdesk_ticket": bool(helpdesk_ticket.get("id")),
|
||||||
|
"helpdesk_ticket_id": helpdesk_ticket.get("id"),
|
||||||
|
"contact_id": contact.get("id"),
|
||||||
|
"contact_email": contact.get("email"),
|
||||||
|
"contact_name": contact.get("name"),
|
||||||
|
"contact_company": contact.get("company"),
|
||||||
|
"created_on": created_on,
|
||||||
|
"updated_on": updated_on,
|
||||||
|
"visibility": "public",
|
||||||
|
"redmine_url": redmine_url,
|
||||||
|
"source_record_id": source_record_id,
|
||||||
|
"source_hash": stable_hash(record),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _issue_contact(self, issue: Issue) -> Payload:
|
||||||
|
contact = issue.get("contact") or issue.get("customer") or {}
|
||||||
|
helpdesk_ticket = issue.get("helpdesk_ticket") or {}
|
||||||
|
helpdesk_contact = helpdesk_ticket.get("contact") or {}
|
||||||
|
merged = {**helpdesk_contact, **contact}
|
||||||
|
if not merged.get("id"):
|
||||||
|
merged["id"] = helpdesk_ticket.get("contact_id")
|
||||||
|
if not merged.get("email"):
|
||||||
|
merged["email"] = helpdesk_ticket.get("contact_email") or helpdesk_ticket.get("from_address")
|
||||||
|
if not merged.get("name"):
|
||||||
|
merged["name"] = helpdesk_ticket.get("contact_name")
|
||||||
|
if not merged.get("company"):
|
||||||
|
merged["company"] = helpdesk_ticket.get("contact_company")
|
||||||
|
return {key: value for key, value in merged.items() if value not in (None, "")}
|
||||||
|
|
||||||
|
def _contact_text(self, contact: Payload) -> str:
|
||||||
|
text_parts = [
|
||||||
|
contact.get("name"),
|
||||||
|
contact.get("email"),
|
||||||
|
contact.get("phone"),
|
||||||
|
contact.get("company"),
|
||||||
|
]
|
||||||
|
return "\n".join(str(part) for part in text_parts if part)
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineApiSource:
|
||||||
|
def __init__(self, redmine_url: str, api_key: str, project_identifier: Optional[str] = None) -> None:
|
||||||
|
self.redmine_url = redmine_url.rstrip("/")
|
||||||
|
self.api_key = api_key
|
||||||
|
self.project_identifier = project_identifier
|
||||||
|
|
||||||
|
def recent_helpdesk_issues(self, limit: int) -> Iterable[Issue]:
|
||||||
|
for issue in self.recent_issue_summaries(limit):
|
||||||
|
yield self.issue_detail(int(issue["id"]), fallback=issue)
|
||||||
|
|
||||||
|
def recent_issue_summaries(self, limit: int) -> Iterable[Issue]:
|
||||||
|
yielded = 0
|
||||||
|
offset = 0
|
||||||
|
seen_issue_ids = set()
|
||||||
|
page_size = 100
|
||||||
|
while yielded < limit:
|
||||||
|
current_limit = min(page_size, limit - yielded)
|
||||||
|
params = {
|
||||||
|
"limit": str(current_limit),
|
||||||
|
"offset": str(offset),
|
||||||
|
"sort": "updated_on:desc,id:desc",
|
||||||
|
"include": "journals",
|
||||||
|
"status_id": "*",
|
||||||
|
}
|
||||||
|
if self.project_identifier:
|
||||||
|
params["project_id"] = self.project_identifier
|
||||||
|
params["subproject_id"] = "!*"
|
||||||
|
path = f"{self.redmine_url}/issues.json?{urllib.parse.urlencode(params)}"
|
||||||
|
payload = self._get_json(path)
|
||||||
|
issues = payload.get("issues", [])
|
||||||
|
if not issues:
|
||||||
|
break
|
||||||
|
for issue in issues:
|
||||||
|
issue_id = issue["id"]
|
||||||
|
if issue_id in seen_issue_ids:
|
||||||
|
continue
|
||||||
|
seen_issue_ids.add(issue_id)
|
||||||
|
issue.setdefault("url", f"{self.redmine_url}/issues/{issue_id}")
|
||||||
|
yield issue
|
||||||
|
yielded += 1
|
||||||
|
if yielded >= limit:
|
||||||
|
break
|
||||||
|
offset += len(issues)
|
||||||
|
|
||||||
|
def issue_detail(self, issue_id: int, fallback: Optional[Issue] = None) -> Issue:
|
||||||
|
detail_params = urllib.parse.urlencode({"include": "journals,helpdesk"})
|
||||||
|
detail = self._get_json(f"{self.redmine_url}/issues/{issue_id}.json?{detail_params}")
|
||||||
|
merged = {**(fallback or {}), **detail.get("issue", {})}
|
||||||
|
merged.setdefault("url", f"{self.redmine_url}/issues/{issue_id}")
|
||||||
|
return merged
|
||||||
|
|
||||||
|
def _get_json(self, url: str) -> Dict[str, Any]:
|
||||||
|
request = urllib.request.Request(url, headers={"X-Redmine-API-Key": self.api_key, "Accept": "application/json"})
|
||||||
|
with urllib.request.urlopen(request, timeout=30) as response:
|
||||||
|
return json.loads(response.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def stable_hash(record: Dict[str, Any]) -> str:
|
||||||
|
canonical = json.dumps(record, sort_keys=True, separators=(",", ":"), default=str)
|
||||||
|
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Iterable, List, Optional, Protocol, Sequence
|
||||||
|
|
||||||
|
from .ingest import deduplicate_documents
|
||||||
|
from .models import IndexDocument
|
||||||
|
from .redmine import RedmineMapper
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineRefreshSource(Protocol):
|
||||||
|
project_identifier: str | None
|
||||||
|
|
||||||
|
def recent_helpdesk_issues(self, limit: int) -> Iterable[Dict[str, Any]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshEmbedder(Protocol):
|
||||||
|
def embed_documents(self, docs: Sequence[IndexDocument]) -> List[List[float]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshStore(Protocol):
|
||||||
|
def list_documents(
|
||||||
|
self,
|
||||||
|
limit: int = 10,
|
||||||
|
source: Optional[str] = None,
|
||||||
|
project_identifier: Optional[str] = None,
|
||||||
|
doc_type: Optional[str] = None,
|
||||||
|
issue_id: Optional[int] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def upsert(self, docs: Sequence[IndexDocument], vectors: Sequence[Sequence[float]]) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def delete_documents(self, document_ids: Sequence[str]) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class FileRefreshState:
|
||||||
|
def __init__(self, path: Path) -> None:
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
def load(self) -> Dict[str, Any]:
|
||||||
|
if not self.path.exists():
|
||||||
|
return {}
|
||||||
|
return json.loads(self.path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def mark_success(self, project_identifier: str, timestamp: Optional[str] = None) -> None:
|
||||||
|
payload = self.load()
|
||||||
|
payload.setdefault("projects", {})
|
||||||
|
payload["projects"][project_identifier] = {
|
||||||
|
"last_successful_refresh_at": timestamp or datetime.now(timezone.utc).isoformat()
|
||||||
|
}
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineRefreshService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
source: RedmineRefreshSource,
|
||||||
|
embedder: RefreshEmbedder,
|
||||||
|
store: RefreshStore,
|
||||||
|
mapper: Optional[RedmineMapper] = None,
|
||||||
|
state: Optional[FileRefreshState] = None,
|
||||||
|
) -> None:
|
||||||
|
self.source = source
|
||||||
|
self.embedder = embedder
|
||||||
|
self.store = store
|
||||||
|
self.mapper = mapper or RedmineMapper(redmine_url="")
|
||||||
|
self.state = state
|
||||||
|
|
||||||
|
def refresh_redmine_project_limits(
|
||||||
|
self,
|
||||||
|
project_limits: Dict[str, int],
|
||||||
|
dry_run: bool = False,
|
||||||
|
force_rebuild: bool = False,
|
||||||
|
overlap_minutes: int = 15,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
previous_source_project = getattr(self.source, "project_identifier", None)
|
||||||
|
previous_mapper_project = getattr(self.mapper, "project_identifier", None)
|
||||||
|
project_results: List[Dict[str, Any]] = []
|
||||||
|
totals = {
|
||||||
|
"issues": 0,
|
||||||
|
"scanned_issues": 0,
|
||||||
|
"detail_fetched_issues": 0,
|
||||||
|
"skipped_issues": 0,
|
||||||
|
"documents": 0,
|
||||||
|
"unchanged_documents": 0,
|
||||||
|
"changed_documents": 0,
|
||||||
|
"new_documents": 0,
|
||||||
|
"stale_documents": 0,
|
||||||
|
"force_rebuilt_documents": 0,
|
||||||
|
"would_embed_documents": 0,
|
||||||
|
"embedded_documents": 0,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
for project, limit in project_limits.items():
|
||||||
|
if hasattr(self.source, "project_identifier"):
|
||||||
|
self.source.project_identifier = project
|
||||||
|
if hasattr(self.mapper, "project_identifier"):
|
||||||
|
self.mapper.project_identifier = project
|
||||||
|
project_result = self._refresh_project(project, limit, dry_run, force_rebuild, overlap_minutes)
|
||||||
|
project_results.append(project_result)
|
||||||
|
for key in totals:
|
||||||
|
totals[key] += int(project_result.get(key, 0))
|
||||||
|
if not dry_run and self.state is not None:
|
||||||
|
self.state.mark_success(project)
|
||||||
|
finally:
|
||||||
|
if hasattr(self.source, "project_identifier"):
|
||||||
|
self.source.project_identifier = previous_source_project
|
||||||
|
if hasattr(self.mapper, "project_identifier"):
|
||||||
|
self.mapper.project_identifier = previous_mapper_project
|
||||||
|
return {
|
||||||
|
"source": "redmine",
|
||||||
|
"projects": len(project_limits),
|
||||||
|
"dry_run": dry_run,
|
||||||
|
"force_rebuild": force_rebuild,
|
||||||
|
"overlap_minutes": overlap_minutes,
|
||||||
|
**totals,
|
||||||
|
"project_results": project_results,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _refresh_project(self, project: str, limit: int, dry_run: bool, force_rebuild: bool, overlap_minutes: int) -> Dict[str, Any]:
|
||||||
|
summaries = list(self._recent_issue_summaries(limit))
|
||||||
|
result: Dict[str, Any] = {
|
||||||
|
"project_identifier": project,
|
||||||
|
"issues": len(summaries),
|
||||||
|
"scanned_issues": len(summaries),
|
||||||
|
"detail_fetched_issues": 0,
|
||||||
|
"skipped_issues": 0,
|
||||||
|
"documents": 0,
|
||||||
|
"unchanged_documents": 0,
|
||||||
|
"changed_documents": 0,
|
||||||
|
"new_documents": 0,
|
||||||
|
"stale_documents": 0,
|
||||||
|
"force_rebuilt_documents": 0,
|
||||||
|
"would_embed_documents": 0,
|
||||||
|
"embedded_documents": 0,
|
||||||
|
}
|
||||||
|
cutoff = self._cutoff_for_project(project, overlap_minutes)
|
||||||
|
docs_to_embed: List[IndexDocument] = []
|
||||||
|
stale_ids: List[str] = []
|
||||||
|
for summary in summaries:
|
||||||
|
if cutoff is not None and not force_rebuild and not self._issue_is_in_refresh_window(summary, cutoff):
|
||||||
|
result["skipped_issues"] += 1
|
||||||
|
continue
|
||||||
|
issue = self._issue_detail(summary)
|
||||||
|
result["detail_fetched_issues"] += 1
|
||||||
|
candidates = deduplicate_documents(self.mapper.issue_to_documents(issue))
|
||||||
|
result["documents"] += len(candidates)
|
||||||
|
existing = self.store.list_documents(
|
||||||
|
limit=5000,
|
||||||
|
source="redmine",
|
||||||
|
project_identifier=project,
|
||||||
|
issue_id=int(issue["id"]),
|
||||||
|
)
|
||||||
|
existing_by_id = {document["id"]: document for document in existing}
|
||||||
|
candidate_by_id = {document.id: document for document in candidates}
|
||||||
|
for stale_id in sorted(set(existing_by_id) - set(candidate_by_id)):
|
||||||
|
stale_ids.append(stale_id)
|
||||||
|
result["stale_documents"] += 1
|
||||||
|
for document in candidates:
|
||||||
|
existing_document = existing_by_id.get(document.id)
|
||||||
|
if existing_document is None:
|
||||||
|
result["new_documents"] += 1
|
||||||
|
docs_to_embed.append(document)
|
||||||
|
continue
|
||||||
|
existing_hash = (existing_document.get("payload") or {}).get("source_hash")
|
||||||
|
document_hash = document.payload.get("source_hash")
|
||||||
|
if force_rebuild:
|
||||||
|
result["force_rebuilt_documents"] += 1
|
||||||
|
docs_to_embed.append(document)
|
||||||
|
elif existing_hash != document_hash:
|
||||||
|
result["changed_documents"] += 1
|
||||||
|
docs_to_embed.append(document)
|
||||||
|
else:
|
||||||
|
result["unchanged_documents"] += 1
|
||||||
|
result["would_embed_documents"] = len(docs_to_embed)
|
||||||
|
if dry_run:
|
||||||
|
return result
|
||||||
|
if stale_ids:
|
||||||
|
self.store.delete_documents(stale_ids)
|
||||||
|
if docs_to_embed:
|
||||||
|
vectors = self.embedder.embed_documents(docs_to_embed)
|
||||||
|
self.store.upsert(docs_to_embed, vectors)
|
||||||
|
result["embedded_documents"] = len(docs_to_embed)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _recent_issue_summaries(self, limit: int) -> Iterable[Dict[str, Any]]:
|
||||||
|
if hasattr(self.source, "recent_issue_summaries"):
|
||||||
|
return self.source.recent_issue_summaries(limit) # type: ignore[attr-defined]
|
||||||
|
return self.source.recent_helpdesk_issues(limit)
|
||||||
|
|
||||||
|
def _issue_detail(self, summary: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
if hasattr(self.source, "issue_detail"):
|
||||||
|
return self.source.issue_detail(int(summary["id"])) # type: ignore[attr-defined]
|
||||||
|
return summary
|
||||||
|
|
||||||
|
def _cutoff_for_project(self, project: str, overlap_minutes: int) -> Optional[datetime]:
|
||||||
|
if self.state is None:
|
||||||
|
return None
|
||||||
|
timestamp = ((self.state.load().get("projects") or {}).get(project) or {}).get("last_successful_refresh_at")
|
||||||
|
if not timestamp:
|
||||||
|
return None
|
||||||
|
parsed = parse_redmine_datetime(timestamp)
|
||||||
|
return parsed - timedelta(minutes=overlap_minutes)
|
||||||
|
|
||||||
|
def _issue_is_in_refresh_window(self, issue: Dict[str, Any], cutoff: datetime) -> bool:
|
||||||
|
updated_on = issue.get("updated_on")
|
||||||
|
if not updated_on:
|
||||||
|
return True
|
||||||
|
return parse_redmine_datetime(str(updated_on)) >= cutoff
|
||||||
|
|
||||||
|
|
||||||
|
def parse_redmine_datetime(raw: str) -> datetime:
|
||||||
|
normalized = raw.replace("Z", "+00:00")
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
Executable
+107
@@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
Usage:
|
||||||
|
semantic_index/refresh.sh [--apply] [--dry-run]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
semantic_index/refresh.sh
|
||||||
|
semantic_index/refresh.sh --apply
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
SEMANTIC_INDEX_PROJECT_LIMITS comma-separated project=limit pairs
|
||||||
|
SEMANTIC_INDEX_LOG_DIR default: .cache/semantic_index/logs
|
||||||
|
SEMANTIC_INDEX_STATE_PATH default: .cache/semantic_index/refresh_state.json
|
||||||
|
SEMANTIC_INDEX_OVERLAP_MINUTES default: 15
|
||||||
|
PYTHON default: <install-root>/.venv/bin/python
|
||||||
|
|
||||||
|
This wrapper never passes --force-rebuild. Run force rebuilds manually.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
install_root=$(cd "$script_dir/.." && pwd)
|
||||||
|
|
||||||
|
load_env_defaults() {
|
||||||
|
local file=$1
|
||||||
|
local key value
|
||||||
|
[[ -r "$file" ]] || return 0
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
line=${line#"${line%%[![:space:]]*}"}
|
||||||
|
line=${line%"${line##*[![:space:]]}"}
|
||||||
|
[[ -z "$line" || "$line" == \#* || "$line" != *=* ]] && continue
|
||||||
|
key=${line%%=*}
|
||||||
|
value=${line#*=}
|
||||||
|
key=${key%"${key##*[![:space:]]}"}
|
||||||
|
value=${value#"${value%%[![:space:]]*}"}
|
||||||
|
value=${value%"${value##*[![:space:]]}"}
|
||||||
|
value=${value%\"}
|
||||||
|
value=${value#\"}
|
||||||
|
value=${value%\'}
|
||||||
|
value=${value#\'}
|
||||||
|
if [[ -z "${!key+x}" ]]; then
|
||||||
|
export "$key=$value"
|
||||||
|
fi
|
||||||
|
done < "$file"
|
||||||
|
}
|
||||||
|
|
||||||
|
load_env_defaults /etc/semantic-index.env
|
||||||
|
|
||||||
|
mode=dry-run
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--apply)
|
||||||
|
mode=apply
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--dry-run)
|
||||||
|
mode=dry-run
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
project_limits=${SEMANTIC_INDEX_PROJECT_LIMITS:-customer-service=500,hiring=200,todo-jason=200,sales-inbox=100,business-development=100,dock-scheduling=100,prep-standardization=100}
|
||||||
|
log_dir=${SEMANTIC_INDEX_LOG_DIR:-.cache/semantic_index/logs}
|
||||||
|
state_path=${SEMANTIC_INDEX_STATE_PATH:-.cache/semantic_index/refresh_state.json}
|
||||||
|
overlap_minutes=${SEMANTIC_INDEX_OVERLAP_MINUTES:-15}
|
||||||
|
python_bin=${PYTHON:-$install_root/.venv/bin/python}
|
||||||
|
|
||||||
|
mkdir -p "$log_dir" "$(dirname "$state_path")"
|
||||||
|
timestamp=$(date -u +"%Y%m%dT%H%M%SZ")
|
||||||
|
log_file="$log_dir/redmine-refresh-$timestamp.log"
|
||||||
|
|
||||||
|
args=(
|
||||||
|
-m semantic_index
|
||||||
|
--refresh-redmine-projects
|
||||||
|
--project-limits "$project_limits"
|
||||||
|
--state-path "$state_path"
|
||||||
|
--overlap-minutes "$overlap_minutes"
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ "$mode" == "dry-run" ]]; then
|
||||||
|
args+=(--dry-run)
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
printf 'started_at=%s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||||
|
printf 'mode=%s\n' "$mode"
|
||||||
|
printf 'project_limits=%s\n' "$project_limits"
|
||||||
|
printf 'state_path=%s\n' "$state_path"
|
||||||
|
printf 'overlap_minutes=%s\n' "$overlap_minutes"
|
||||||
|
cd "$install_root"
|
||||||
|
"$python_bin" "${args[@]}"
|
||||||
|
printf '\nfinished_at=%s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||||
|
} 2>&1 | tee "$log_file"
|
||||||
|
|
||||||
|
printf 'log_file=%s\n' "$log_file"
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Dict, List, Optional, Protocol
|
||||||
|
|
||||||
|
from .models import SearchQuery, SearchResult
|
||||||
|
|
||||||
|
|
||||||
|
class QueryEmbedder(Protocol):
|
||||||
|
def embed_query(self, text: str) -> List[float]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class SearchStore(Protocol):
|
||||||
|
def search(self, vector: List[float], query: SearchQuery, limit: int) -> List[SearchResult]:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_document(self, document_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class HybridSearchService:
|
||||||
|
def __init__(self, embedder: QueryEmbedder, store: SearchStore) -> None:
|
||||||
|
self.embedder = embedder
|
||||||
|
self.store = store
|
||||||
|
|
||||||
|
def search(self, query: SearchQuery) -> List[SearchResult]:
|
||||||
|
vector = self.embedder.embed_query(query.text)
|
||||||
|
candidates = self.store.search(vector, query, limit=query.limit)
|
||||||
|
rescored = [
|
||||||
|
SearchResult(
|
||||||
|
id=result.id,
|
||||||
|
score=result.score + keyword_boost(query.text, result),
|
||||||
|
text=result.text,
|
||||||
|
payload=result.payload,
|
||||||
|
)
|
||||||
|
for result in candidates
|
||||||
|
]
|
||||||
|
return sorted(rescored, key=lambda result: result.score, reverse=True)[: query.limit]
|
||||||
|
|
||||||
|
def get_document(self, document_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
return self.store.get_document(document_id)
|
||||||
|
|
||||||
|
|
||||||
|
def keyword_boost(query_text: str, result: SearchResult) -> float:
|
||||||
|
haystack = " ".join([result.text, " ".join(str(value) for value in result.payload.values() if value is not None)]).lower()
|
||||||
|
boost = 0.0
|
||||||
|
for phrase in re.findall(r'"([^"]+)"', query_text):
|
||||||
|
if phrase.lower() in haystack:
|
||||||
|
boost += 0.35
|
||||||
|
for email in re.findall(r"[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}", query_text):
|
||||||
|
if email.lower() in haystack:
|
||||||
|
boost += 0.3
|
||||||
|
for token in re.findall(r"\b(?:#?\d{2,}|[A-Z]{2,}[-_]\d{2,}|[A-Z0-9]{4,}-[A-Z0-9-]{2,})\b", query_text):
|
||||||
|
normalized = token.lower().lstrip("#")
|
||||||
|
if token.lower() in haystack or normalized in haystack:
|
||||||
|
boost += 0.25
|
||||||
|
for word in re.findall(r"\b[A-Za-z][\w.-]{2,}\b", query_text):
|
||||||
|
if word.lower() in haystack:
|
||||||
|
boost += 0.03
|
||||||
|
return boost
|
||||||
Executable
+71
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
Usage:
|
||||||
|
semantic_index/search.sh "query text" [project_identifier] [limit]
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
semantic_index/search.sh "goods return" customer-service 3
|
||||||
|
semantic_index/search.sh "candidate follow up" hiring 5 | jq '.results[] | {id, score, citation}'
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
SEMANTIC_INDEX_URL default: http://127.0.0.1:8787
|
||||||
|
SEMANTIC_INDEX_API_KEY optional; falls back to semantic_index/.env or .env
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ $# -lt 1 ]]; then
|
||||||
|
usage
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
query=$1
|
||||||
|
project=${2:-}
|
||||||
|
limit=${3:-10}
|
||||||
|
base_url=${SEMANTIC_INDEX_URL:-http://127.0.0.1:8787}
|
||||||
|
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
install_root=$(cd "$script_dir/.." && pwd)
|
||||||
|
|
||||||
|
read_env_value() {
|
||||||
|
local key=$1
|
||||||
|
local file
|
||||||
|
for file in /etc/semantic-index.env "$install_root/semantic_index/.env" "$install_root/.env" semantic_index/.env .env; do
|
||||||
|
if [[ -f "$file" ]]; then
|
||||||
|
awk -F= -v key="$key" '
|
||||||
|
$1 == key {
|
||||||
|
value = substr($0, index($0, "=") + 1)
|
||||||
|
gsub(/^[ \t"'\''"]+|[ \t"'\''"]+$/, "", value)
|
||||||
|
print value
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
' "$file"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
json_escape() {
|
||||||
|
sed \
|
||||||
|
-e 's/\\/\\\\/g' \
|
||||||
|
-e 's/"/\\"/g' \
|
||||||
|
-e ':a;N;$!ba;s/\n/\\n/g'
|
||||||
|
}
|
||||||
|
|
||||||
|
escaped_query=$(printf '%s' "$query" | json_escape)
|
||||||
|
payload="{\"query\":\"$escaped_query\",\"limit\":$limit"
|
||||||
|
if [[ -n "$project" ]]; then
|
||||||
|
escaped_project=$(printf '%s' "$project" | json_escape)
|
||||||
|
payload="$payload,\"project_identifier\":\"$escaped_project\""
|
||||||
|
fi
|
||||||
|
payload="$payload}"
|
||||||
|
|
||||||
|
api_key=${SEMANTIC_INDEX_API_KEY:-$(read_env_value SEMANTIC_INDEX_API_KEY)}
|
||||||
|
|
||||||
|
args=(-sS -H "Content-Type: application/json" -d "$payload")
|
||||||
|
if [[ -n "${api_key:-}" ]]; then
|
||||||
|
args+=(-H "Authorization: Bearer $api_key")
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl "${args[@]}" "$base_url/search"
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
• Added semantic_index/search.sh. It outputs raw JSON from the running HTTP service.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
semantic_index/search.sh "goods return" customer-service 3
|
||||||
|
semantic_index/search.sh "candidate follow up" hiring 5 | jq '.results[] | {id, score, citation}'
|
||||||
|
|
||||||
|
It defaults to http://127.0.0.1:8787, reads SEMANTIC_INDEX_API_KEY from the environment or semantic_i
|
||||||
|
ndex/.env, and accepts:
|
||||||
|
|
||||||
|
semantic_index/search.sh "query text" [project_identifier] [limit]
|
||||||
|
|
||||||
|
Verified:
|
||||||
|
|
||||||
|
- Script works against the local service.
|
||||||
|
- Semantic tests still pass: 49 tests, OK.
|
||||||
|
- Python compile check passed.
|
||||||
|
|
||||||
|
|
||||||
|
› Okay yeah, that's nice and fast
|
||||||
|
|
||||||
|
|
||||||
|
• Good. That script is the right shape for quick manual probing: fast because it avoids Python startup,
|
||||||
|
plain JSON so it pipes cleanly, and thin enough that it won’t hide what the API is returning.
|
||||||
|
|
||||||
|
A couple useful examples:
|
||||||
|
|
||||||
|
semantic_index/search.sh "goods return" customer-service 3 | jq '.results[].citation'
|
||||||
|
semantic_index/search.sh "candidate follow up" hiring 5 | jq -r '.results[] | "\(.score)
|
||||||
|
\(.citation.url) \(.snippet)"'
|
||||||
|
semantic_index/search.sh "callum@safetagtracking.com" customer-service 3 | jq '.results[0]'
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
name: redmine-communicator
|
||||||
|
description: Use when an agent needs to install, configure, or operate the redMCP MCP server to communicate with Redmine, including Helpdesk-aware issue reads, safe issue updates, attachment handling, and explicit customer-visible Helpdesk responses.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Redmine Communicator
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Use this skill to connect an agent to Redmine through `redMCP`, a PHP MCP server
|
||||||
|
that wraps Redmine's REST API and LDR's Helpdesk-aware extensions.
|
||||||
|
|
||||||
|
Use `redMCP` instead of ad hoc HTTP calls when the task involves Redmine issues,
|
||||||
|
projects, users, attachments, project categories, issue relations, or Helpdesk
|
||||||
|
customer communications.
|
||||||
|
|
||||||
|
## Setup Workflow
|
||||||
|
|
||||||
|
1. Install or stage `redMCP` from this repository:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 skills/redmine-communicator/scripts/setup_redmcp.py \
|
||||||
|
--redmine-url http://redmine.example.test \
|
||||||
|
--redmine-api-key "$REDMINE_API_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
The default is dry-run. Add `--apply` to copy files and write `.env`.
|
||||||
|
|
||||||
|
2. Configure the MCP client with the printed stdio config. The command points to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<install-dir>/bin/redmcp-server.php
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify the server from the agent or client by listing tools. If using the
|
||||||
|
Streamable HTTP transport, generate and configure `MCP_SERVER_TOKEN`.
|
||||||
|
|
||||||
|
4. Read [references/redmcp-tools.md](references/redmcp-tools.md) before making
|
||||||
|
customer-visible changes or using less common tools.
|
||||||
|
|
||||||
|
## Operating Rules
|
||||||
|
|
||||||
|
- Prefer read-only tools first: list/search projects, issues, users, categories,
|
||||||
|
memberships, and Helpdesk context before changing anything.
|
||||||
|
- For Helpdesk-backed issues, use `redmine_issue_with_helpdesk` instead of a
|
||||||
|
plain issue read when customer identity or email context matters.
|
||||||
|
- `redmine_update_issue` is internal-note safe by default. It does **not** send
|
||||||
|
Helpdesk customer email unless `options.send_helpdesk_email=true` is passed.
|
||||||
|
- Use `redmine_send_helpdesk_response` only when the user explicitly wants a
|
||||||
|
customer-visible Helpdesk email.
|
||||||
|
- Do not invent Redmine project identifiers, tracker ids, category ids, or user
|
||||||
|
ids. Discover them with redMCP tools first.
|
||||||
|
- Do not put Redmine API keys, MCP bearer tokens, passwords, or customer secrets
|
||||||
|
in logs, committed files, or final answers.
|
||||||
|
|
||||||
|
## Common Tool Choices
|
||||||
|
|
||||||
|
- Find work: `redmine_list_issues`, `redmine_search`, `redmine_search_issues`.
|
||||||
|
- Read one issue: `redmine_get_issue`; use `redmine_issue_with_helpdesk` for
|
||||||
|
Helpdesk/customer context.
|
||||||
|
- Internal update: `redmine_update_issue` with fields only.
|
||||||
|
- Customer reply: `redmine_send_helpdesk_response`, or
|
||||||
|
`redmine_update_issue` with `options.send_helpdesk_email=true`.
|
||||||
|
- Attachments: `redmine_upload_attachment`, then include returned upload token
|
||||||
|
in issue create/update; `redmine_download_attachment` only to safe local paths.
|
||||||
|
- Structure: issue relation, parent/child, project category, project membership,
|
||||||
|
project, and user tools are available through MCP.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- If `redmcp-server.php` fails immediately, check that `.env` contains
|
||||||
|
`REDMINE_URL` and `REDMINE_API_KEY`.
|
||||||
|
- If PHP autoloading fails, run `composer install` in the `redMCP` install
|
||||||
|
directory, or install from a package that includes `vendor/`.
|
||||||
|
- If HTTP transport is used, `MCP_SERVER_TOKEN` is required and clients must send
|
||||||
|
`Authorization: Bearer <token>`.
|
||||||
|
- Debug logging can include customer text and issue notes. Enable it only for
|
||||||
|
local troubleshooting and store logs somewhere private.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
interface:
|
||||||
|
display_name: "Redmine Communicator"
|
||||||
|
short_description: "Connect agents to Redmine through redMCP"
|
||||||
|
default_prompt: "Use $redmine-communicator to connect to Redmine, inspect issues, and make safe Helpdesk-aware updates."
|
||||||
|
dependencies:
|
||||||
|
tools:
|
||||||
|
- type: "mcp"
|
||||||
|
value: "redmcp"
|
||||||
|
description: "Local redMCP server exposing Redmine and Helpdesk-aware tools."
|
||||||
|
transport: "stdio"
|
||||||
|
policy:
|
||||||
|
allow_implicit_invocation: true
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# 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=...
|
||||||
|
```
|
||||||
|
|
||||||
|
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`.
|
||||||
|
|
||||||
|
## Write Tools
|
||||||
|
|
||||||
|
- `redmine_create_issue`: create an issue.
|
||||||
|
- `redmine_update_issue`: update fields or add an internal note. Helpdesk email
|
||||||
|
is opt-in with `options.send_helpdesk_email=true`.
|
||||||
|
- `redmine_send_helpdesk_response`: send a customer-visible Helpdesk email.
|
||||||
|
- `redmine_create_issue_relation`, `redmine_remove_issue_relation`.
|
||||||
|
- `redmine_set_issue_parent`, `redmine_clear_issue_parent`.
|
||||||
|
- `redmine_create_issue_category`, `redmine_update_issue_category`.
|
||||||
|
- `redmine_upload_attachment`, `redmine_download_attachment`,
|
||||||
|
`redmine_update_attachment`.
|
||||||
|
|
||||||
|
## Safety Notes
|
||||||
|
|
||||||
|
- Customer-visible email requires explicit intent. Prefer internal notes unless
|
||||||
|
the user asks to email the customer.
|
||||||
|
- Deletion tools for issues, projects, users, categories, and attachments are
|
||||||
|
intentionally not exposed. Relation removal only unlinks the relationship.
|
||||||
|
- For Helpdesk workflows, read with `redmine_issue_with_helpdesk` before
|
||||||
|
replying so the agent sees customer/contact context.
|
||||||
|
- For file uploads, use `redmine_upload_attachment` with a path, base64 content,
|
||||||
|
data URL, or file envelope. Use data/file inputs for PDFs and non-image files.
|
||||||
|
- `redmine_download_attachment` requires an explicit path under `/tmp` or the
|
||||||
|
repository tree and limits optional base64 response size.
|
||||||
|
|
||||||
|
## Example MCP Client Config
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"redmcp": {
|
||||||
|
"command": "/path/to/redMCP/bin/redmcp-server.php"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Calls
|
||||||
|
|
||||||
|
Read Helpdesk-aware issue context:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "redmine_issue_with_helpdesk",
|
||||||
|
"arguments": {
|
||||||
|
"issue_id": 39858,
|
||||||
|
"include": ["journals", "attachments"],
|
||||||
|
"message_limit": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Internal note:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "redmine_update_issue",
|
||||||
|
"arguments": {
|
||||||
|
"issue_id": 39858,
|
||||||
|
"fields": {
|
||||||
|
"notes": "Internal follow-up note."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Customer-visible Helpdesk reply:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "redmine_send_helpdesk_response",
|
||||||
|
"arguments": {
|
||||||
|
"issue_id": 39858,
|
||||||
|
"content": "Customer-visible response text."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Install or stage redMCP for another agent's MCP client."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_INSTALL_DIR = Path.home() / ".local" / "share" / "redmcp"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Install redMCP and print MCP client configuration.")
|
||||||
|
parser.add_argument("--source-redmcp", type=Path, default=repo_root() / "redMCP")
|
||||||
|
parser.add_argument("--install-dir", type=Path, default=DEFAULT_INSTALL_DIR)
|
||||||
|
parser.add_argument("--redmine-url", required=True)
|
||||||
|
parser.add_argument("--redmine-api-key", required=True)
|
||||||
|
parser.add_argument("--transport", choices=["stdio", "http"], default="stdio")
|
||||||
|
parser.add_argument("--mcp-server-token", default="")
|
||||||
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
|
parser.add_argument("--port", type=int, default=8765)
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Copy files and write .env. Default is dry-run.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.transport == "http" and not args.mcp_server_token:
|
||||||
|
print("error: --mcp-server-token is required for --transport http", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if not args.source_redmcp.is_dir():
|
||||||
|
print("error: source redMCP directory not found: {0}".format(args.source_redmcp), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print("mode={0}".format("apply" if args.apply else "dry-run"))
|
||||||
|
print("source_redmcp={0}".format(args.source_redmcp))
|
||||||
|
print("install_dir={0}".format(args.install_dir))
|
||||||
|
|
||||||
|
if args.apply:
|
||||||
|
copy_redmcp(args.source_redmcp, args.install_dir)
|
||||||
|
write_env(args.install_dir / ".env", args)
|
||||||
|
ensure_executable(args.install_dir / "bin" / "redmcp-server.php")
|
||||||
|
ensure_executable(args.install_dir / "bin" / "redmcp-http-server.php")
|
||||||
|
else:
|
||||||
|
print("would copy redMCP files")
|
||||||
|
print("would write {0} with REDMINE_URL and REDMINE_API_KEY".format(args.install_dir / ".env"))
|
||||||
|
|
||||||
|
print_runtime_notes(args)
|
||||||
|
print_mcp_config(args)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def repo_root():
|
||||||
|
return Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
|
||||||
|
def copy_redmcp(source, target):
|
||||||
|
if target.exists():
|
||||||
|
shutil.rmtree(str(target))
|
||||||
|
ignore = shutil.ignore_patterns(".env", ".cache", "__pycache__", "*.pyc", "*.log")
|
||||||
|
shutil.copytree(str(source), str(target), ignore=ignore)
|
||||||
|
|
||||||
|
|
||||||
|
def write_env(path, args):
|
||||||
|
lines = [
|
||||||
|
"REDMINE_URL={0}".format(args.redmine_url.rstrip("/")),
|
||||||
|
"REDMINE_API_KEY={0}".format(args.redmine_api_key),
|
||||||
|
]
|
||||||
|
if args.transport == "http":
|
||||||
|
lines.append("MCP_SERVER_TOKEN={0}".format(args.mcp_server_token))
|
||||||
|
path.write_text("\n".join(lines) + "\n")
|
||||||
|
os.chmod(str(path), 0o600)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_executable(path):
|
||||||
|
if path.exists():
|
||||||
|
mode = path.stat().st_mode
|
||||||
|
path.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||||
|
|
||||||
|
|
||||||
|
def print_runtime_notes(args):
|
||||||
|
composer = shutil.which("composer")
|
||||||
|
vendor = args.install_dir / "vendor" / "autoload.php"
|
||||||
|
if args.apply and not vendor.exists():
|
||||||
|
if composer:
|
||||||
|
print("vendor/autoload.php missing; run: cd {0} && composer install".format(args.install_dir))
|
||||||
|
else:
|
||||||
|
print("vendor/autoload.php missing and composer was not found on PATH")
|
||||||
|
elif not args.apply:
|
||||||
|
print("after apply, ensure vendor/autoload.php exists or run composer install in the install dir")
|
||||||
|
|
||||||
|
|
||||||
|
def print_mcp_config(args):
|
||||||
|
if args.transport == "stdio":
|
||||||
|
config = {
|
||||||
|
"mcpServers": {
|
||||||
|
"redmcp": {
|
||||||
|
"command": str(args.install_dir / "bin" / "redmcp-server.php")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
print(json.dumps(config, indent=2))
|
||||||
|
return
|
||||||
|
|
||||||
|
command = "{0} --host {1} --port {2}".format(
|
||||||
|
args.install_dir / "bin" / "redmcp-http-server.php",
|
||||||
|
args.host,
|
||||||
|
args.port,
|
||||||
|
)
|
||||||
|
print("start_http_server={0}".format(command))
|
||||||
|
print("mcp_url=http://{0}:{1}/mcp".format(args.host, args.port))
|
||||||
|
print("authorization=Bearer <MCP_SERVER_TOKEN>")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Executable
+66
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Stage post-import automation files into the shared LAN scratch directory."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TARGET = Path("/opt/lanscratch/redmine-post-import/repo")
|
||||||
|
|
||||||
|
PAYLOAD_PATHS = (
|
||||||
|
"plugins",
|
||||||
|
"docs",
|
||||||
|
"semantic_index",
|
||||||
|
"deploy",
|
||||||
|
"dist",
|
||||||
|
"post_import_refresh.py",
|
||||||
|
"stage_post_import_payload.py",
|
||||||
|
"reset_helpdesk_mail_settings.py",
|
||||||
|
"validate_test_instance.py",
|
||||||
|
"redmine_outbox_worker.py",
|
||||||
|
"redMCP",
|
||||||
|
)
|
||||||
|
|
||||||
|
EXCLUDES = (
|
||||||
|
".env",
|
||||||
|
".venv",
|
||||||
|
".cache",
|
||||||
|
"__pycache__/",
|
||||||
|
"*.pyc",
|
||||||
|
"*.tar.gz",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Copy the post-import automation payload into /opt/lanscratch."
|
||||||
|
)
|
||||||
|
parser.add_argument("--target", type=Path, default=DEFAULT_TARGET)
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Run rsync. Default is dry-run.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
repo_root = Path(__file__).resolve().parent
|
||||||
|
command = build_rsync_command(repo_root, args.target)
|
||||||
|
if not args.apply:
|
||||||
|
print("mode=dry-run")
|
||||||
|
print(f"would run: mkdir -p {shlex.quote(str(args.target))}")
|
||||||
|
print(f"would run: {command}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
args.target.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"running: {command}")
|
||||||
|
result = subprocess.run(command, shell=True, check=False)
|
||||||
|
return result.returncode
|
||||||
|
|
||||||
|
|
||||||
|
def build_rsync_command(repo_root: Path, target: Path) -> str:
|
||||||
|
exclude_args = " ".join(f"--exclude {shlex.quote(pattern)}" for pattern in EXCLUDES)
|
||||||
|
sources = " ".join(shlex.quote(str(repo_root / path)) for path in PAYLOAD_PATHS)
|
||||||
|
return f"rsync -a --delete {exclude_args} {sources} {shlex.quote(str(target))}/"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
require 'minitest/autorun'
|
||||||
|
require_relative '../../plugins/redmine_contacts_helpdesk/lib/redmine_helpdesk/issue_api_serializer'
|
||||||
|
|
||||||
|
Contact = Struct.new(:id, :name, :company, :primary_email, :emails, keyword_init: true)
|
||||||
|
Ticket = Struct.new(:id, :contact_id, :message_id, :source, :from_address, :to_address, :cc_address, :ticket_date, :customer, keyword_init: true) do
|
||||||
|
def is_incoming?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
Issue = Struct.new(:helpdesk_ticket, keyword_init: true)
|
||||||
|
|
||||||
|
class IssueApiSerializerTest < Minitest::Test
|
||||||
|
def test_serializes_helpdesk_ticket_with_expanded_contact
|
||||||
|
contact = Contact.new(
|
||||||
|
:id => 1890,
|
||||||
|
:name => 'Callum Mackeonis',
|
||||||
|
:company => 'SafeTag Tracking',
|
||||||
|
:primary_email => 'callum@safetagtracking.com',
|
||||||
|
:emails => ['callum@safetagtracking.com']
|
||||||
|
)
|
||||||
|
ticket = Ticket.new(
|
||||||
|
:id => 35159,
|
||||||
|
:contact_id => 1890,
|
||||||
|
:message_id => 'message-id',
|
||||||
|
:source => 0,
|
||||||
|
:from_address => 'callum@safetagtracking.com',
|
||||||
|
:to_address => 'contact@ldrprep.com',
|
||||||
|
:cc_address => '',
|
||||||
|
:ticket_date => Time.utc(2026, 4, 14, 10, 18, 38),
|
||||||
|
:customer => contact
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = RedmineHelpdesk::IssueApiSerializer.serialize(Issue.new(:helpdesk_ticket => ticket))
|
||||||
|
|
||||||
|
assert_equal 35159, payload[:id]
|
||||||
|
assert_equal 1890, payload[:contact_id]
|
||||||
|
assert_equal 'callum@safetagtracking.com', payload[:contact][:email]
|
||||||
|
assert_equal 'Callum Mackeonis', payload[:contact][:name]
|
||||||
|
assert_equal 'SafeTag Tracking', payload[:contact][:company]
|
||||||
|
assert_equal '2026-04-14T10:18:38Z', payload[:ticket_date]
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_returns_nil_for_non_helpdesk_issue
|
||||||
|
assert_nil RedmineHelpdesk::IssueApiSerializer.serialize(Issue.new(:helpdesk_ticket => nil))
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from semantic_index.app import create_app
|
||||||
|
from semantic_index.config import Settings
|
||||||
|
from semantic_index.models import SearchResult
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSearchService:
|
||||||
|
def search(self, query):
|
||||||
|
return [
|
||||||
|
SearchResult(
|
||||||
|
id="redmine:issue:1:chunk:0",
|
||||||
|
score=0.8,
|
||||||
|
text="Snippet text",
|
||||||
|
payload={
|
||||||
|
"source": "redmine",
|
||||||
|
"project_identifier": "customer-service",
|
||||||
|
"doc_type": "issue",
|
||||||
|
"issue_id": 1,
|
||||||
|
"redmine_url": "http://redmine/issues/1",
|
||||||
|
"source_record_id": "issue:1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_document(self, document_id):
|
||||||
|
return {"id": document_id, "text": "Full text", "payload": {}}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
def list_projects(self, source=None, limit=1000):
|
||||||
|
return [{"project_identifier": "customer-service", "document_count": 10}]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRefreshService:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def refresh_redmine_project_limits(self, project_limits, dry_run=False, force_rebuild=False, overlap_minutes=15):
|
||||||
|
self.calls.append((project_limits, dry_run, force_rebuild, overlap_minutes))
|
||||||
|
return {"source": "redmine", "projects": len(project_limits), "dry_run": dry_run}
|
||||||
|
|
||||||
|
|
||||||
|
def fake_services():
|
||||||
|
refresh = FakeRefreshService()
|
||||||
|
return {
|
||||||
|
"settings": Settings(
|
||||||
|
openai_api_key="",
|
||||||
|
qdrant_url="http://qdrant",
|
||||||
|
qdrant_api_key=None,
|
||||||
|
qdrant_collection="semantic",
|
||||||
|
redmine_url="http://redmine",
|
||||||
|
redmine_api_key="",
|
||||||
|
redmine_project_identifier=None,
|
||||||
|
sample_limit=50,
|
||||||
|
bind_host="127.0.0.1",
|
||||||
|
bind_port=8787,
|
||||||
|
service_api_key=None,
|
||||||
|
refresh_state_path=Path(".cache/semantic_index/refresh_state.json"),
|
||||||
|
),
|
||||||
|
"search": FakeSearchService(),
|
||||||
|
"store": FakeStore(),
|
||||||
|
"refresh": refresh,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticIndexAppTest(unittest.TestCase):
|
||||||
|
def test_health_does_not_build_live_services(self):
|
||||||
|
def broken_builder():
|
||||||
|
raise AssertionError("health should not build live clients")
|
||||||
|
|
||||||
|
app = create_app(service_builder=broken_builder)
|
||||||
|
routes = {route.path: route.endpoint for route in app.routes}
|
||||||
|
|
||||||
|
self.assertEqual({"status": "ok"}, routes["/health"]())
|
||||||
|
|
||||||
|
def test_search_endpoint_returns_normalized_agent_response(self):
|
||||||
|
app = create_app(service_builder=fake_services)
|
||||||
|
routes = {route.path: route.endpoint for route in app.routes}
|
||||||
|
|
||||||
|
response = routes["/search"]({"query": "printer", "project_identifier": "customer-service", "limit": 3})
|
||||||
|
|
||||||
|
self.assertEqual("printer", response["query"])
|
||||||
|
self.assertEqual("customer-service", response["filters"]["project_identifier"])
|
||||||
|
self.assertEqual("customer-service", response["results"][0]["citation"]["project_identifier"])
|
||||||
|
|
||||||
|
def test_projects_endpoint_lists_indexed_projects(self):
|
||||||
|
app = create_app(service_builder=fake_services)
|
||||||
|
routes = {route.path: route.endpoint for route in app.routes}
|
||||||
|
|
||||||
|
response = routes["/projects"]()
|
||||||
|
|
||||||
|
self.assertEqual("customer-service", response["projects"][0]["project_identifier"])
|
||||||
|
|
||||||
|
def test_refresh_endpoint_passes_project_limits_and_cost_flags(self):
|
||||||
|
services = fake_services()
|
||||||
|
app = create_app(service_builder=lambda: services)
|
||||||
|
routes = {route.path: route.endpoint for route in app.routes}
|
||||||
|
|
||||||
|
response = routes["/sources/redmine/refresh"](
|
||||||
|
{
|
||||||
|
"project_limits": {"customer-service": 5},
|
||||||
|
"dry_run": True,
|
||||||
|
"force_rebuild": False,
|
||||||
|
"overlap_minutes": 30,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(response["dry_run"])
|
||||||
|
self.assertEqual(({"customer-service": 5}, True, False, 30), services["refresh"].calls[0])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from semantic_index.ingest import BackfillService
|
||||||
|
from semantic_index.mcp import SemanticMCP
|
||||||
|
from semantic_index.models import SearchQuery, SearchResult
|
||||||
|
from semantic_index.redmine import RedmineMapper
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRedmineSource:
|
||||||
|
project_identifier = None
|
||||||
|
|
||||||
|
def recent_helpdesk_issues(self, limit):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"subject": "First",
|
||||||
|
"description": "First body",
|
||||||
|
"project": {"identifier": self.project_identifier},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"subject": "Second",
|
||||||
|
"description": "Second body",
|
||||||
|
"project": {"identifier": self.project_identifier},
|
||||||
|
},
|
||||||
|
][:limit]
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateDocumentRedmineSource:
|
||||||
|
project_identifier = "customer-service"
|
||||||
|
|
||||||
|
def recent_helpdesk_issues(self, limit):
|
||||||
|
return [
|
||||||
|
{"id": 1, "subject": "First", "description": "First body", "project": {"identifier": "customer-service"}},
|
||||||
|
{"id": 1, "subject": "First duplicate", "description": "Duplicate body", "project": {"identifier": "customer-service"}},
|
||||||
|
][:limit]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEmbedder:
|
||||||
|
def embed_documents(self, docs):
|
||||||
|
return [[float(i), 0.0, 0.0] for i, _ in enumerate(docs, start=1)]
|
||||||
|
|
||||||
|
def embed_query(self, text):
|
||||||
|
return [0.1, 0.0, 0.0]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
def __init__(self):
|
||||||
|
self.deleted = []
|
||||||
|
self.upserts = []
|
||||||
|
|
||||||
|
def rebuild_source(self, source, docs, vectors, project_identifier=None):
|
||||||
|
self.deleted.append((source, project_identifier))
|
||||||
|
self.upserts.append((docs, vectors))
|
||||||
|
|
||||||
|
def list_projects(self, source=None, limit=1000):
|
||||||
|
return [
|
||||||
|
{"project_identifier": "customer-service", "document_count": 1684},
|
||||||
|
{"project_identifier": "hiring", "document_count": 409},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRefreshService:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def refresh_redmine_project_limits(self, project_limits, dry_run=False, force_rebuild=False, overlap_minutes=15):
|
||||||
|
self.calls.append((project_limits, dry_run, force_rebuild, overlap_minutes))
|
||||||
|
return {"source": "redmine", "projects": len(project_limits), "dry_run": dry_run}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSearchService:
|
||||||
|
def __init__(self):
|
||||||
|
self.queries = []
|
||||||
|
|
||||||
|
def search(self, query):
|
||||||
|
self.queries.append(query)
|
||||||
|
return [SearchResult(id="doc1", score=0.5, text="Snippet", payload={"redmine_url": "http://redmine/issues/1"})]
|
||||||
|
|
||||||
|
def get_document(self, document_id):
|
||||||
|
return {"id": document_id, "text": "Snippet"}
|
||||||
|
|
||||||
|
|
||||||
|
class BackfillAndMCPTest(unittest.TestCase):
|
||||||
|
def test_sample_backfill_rebuilds_redmine_source(self):
|
||||||
|
service = BackfillService(source=FakeRedmineSource(), embedder=FakeEmbedder(), store=FakeStore())
|
||||||
|
|
||||||
|
result = service.backfill_redmine_sample(limit=2)
|
||||||
|
|
||||||
|
self.assertEqual({"source": "redmine", "issues": 2, "documents": 2}, result)
|
||||||
|
self.assertEqual([("redmine", None)], service.store.deleted)
|
||||||
|
docs, vectors = service.store.upserts[0]
|
||||||
|
self.assertEqual(["redmine:issue:1:chunk:0", "redmine:issue:2:chunk:0"], [doc.id for doc in docs])
|
||||||
|
self.assertEqual(2, len(vectors))
|
||||||
|
|
||||||
|
def test_sample_backfill_rebuilds_only_the_configured_project_scope(self):
|
||||||
|
store = FakeStore()
|
||||||
|
service = BackfillService(
|
||||||
|
source=FakeRedmineSource(),
|
||||||
|
embedder=FakeEmbedder(),
|
||||||
|
store=store,
|
||||||
|
mapper=RedmineMapper(redmine_url="", project_identifier="customer-service"),
|
||||||
|
)
|
||||||
|
|
||||||
|
service.backfill_redmine_sample(limit=1)
|
||||||
|
|
||||||
|
self.assertEqual([("redmine", "customer-service")], store.deleted)
|
||||||
|
|
||||||
|
def test_multi_project_backfill_rebuilds_each_project_scope(self):
|
||||||
|
store = FakeStore()
|
||||||
|
service = BackfillService(source=FakeRedmineSource(), embedder=FakeEmbedder(), store=store)
|
||||||
|
|
||||||
|
result = service.backfill_redmine_projects(["customer-service", "hiring"], per_project_limit=1)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{
|
||||||
|
"source": "redmine",
|
||||||
|
"projects": 2,
|
||||||
|
"issues": 2,
|
||||||
|
"documents": 2,
|
||||||
|
"project_results": [
|
||||||
|
{"project_identifier": "customer-service", "issues": 1, "documents": 1},
|
||||||
|
{"project_identifier": "hiring", "issues": 1, "documents": 1},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
self.assertEqual([("redmine", "customer-service"), ("redmine", "hiring")], store.deleted)
|
||||||
|
self.assertEqual("customer-service", store.upserts[0][0][0].payload["project_identifier"])
|
||||||
|
self.assertEqual("hiring", store.upserts[1][0][0].payload["project_identifier"])
|
||||||
|
|
||||||
|
def test_multi_project_backfill_accepts_per_project_limits(self):
|
||||||
|
store = FakeStore()
|
||||||
|
service = BackfillService(source=FakeRedmineSource(), embedder=FakeEmbedder(), store=store)
|
||||||
|
|
||||||
|
result = service.backfill_redmine_project_limits({"customer-service": 2, "hiring": 1})
|
||||||
|
|
||||||
|
self.assertEqual(3, result["issues"])
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
{"project_identifier": "customer-service", "issues": 2, "documents": 2},
|
||||||
|
{"project_identifier": "hiring", "issues": 1, "documents": 1},
|
||||||
|
],
|
||||||
|
result["project_results"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_backfill_deduplicates_documents_by_stable_id_before_embedding(self):
|
||||||
|
store = FakeStore()
|
||||||
|
service = BackfillService(source=DuplicateDocumentRedmineSource(), embedder=FakeEmbedder(), store=store)
|
||||||
|
|
||||||
|
result = service.backfill_redmine_sample(limit=2)
|
||||||
|
|
||||||
|
self.assertEqual({"source": "redmine", "issues": 2, "documents": 1}, result)
|
||||||
|
docs, vectors = store.upserts[0]
|
||||||
|
self.assertEqual(["redmine:issue:1:chunk:0"], [doc.id for doc in docs])
|
||||||
|
self.assertEqual(1, len(vectors))
|
||||||
|
|
||||||
|
def test_mcp_tools_return_json_ready_results(self):
|
||||||
|
search = FakeSearchService()
|
||||||
|
refresh = FakeRefreshService()
|
||||||
|
mcp = SemanticMCP(search_service=search, backfill_service=None, store=FakeStore(), refresh_service=refresh)
|
||||||
|
|
||||||
|
response = mcp.call_tool("semantic_search", {"query": "printer", "source": "redmine", "project_identifier": "hiring", "limit": 3})
|
||||||
|
document = mcp.call_tool("semantic_get_document", {"id": "doc1"})
|
||||||
|
projects = mcp.call_tool("semantic_list_projects", {"source": "redmine"})
|
||||||
|
refresh_response = mcp.call_tool("semantic_refresh_redmine", {"project_identifier": "customer-service", "limit": 5, "dry_run": True})
|
||||||
|
|
||||||
|
self.assertEqual("printer", response["query"])
|
||||||
|
self.assertEqual("hiring", response["filters"]["project_identifier"])
|
||||||
|
self.assertEqual("doc1", response["results"][0]["id"])
|
||||||
|
self.assertEqual("http://redmine/issues/1", response["results"][0]["citation"]["url"])
|
||||||
|
self.assertIsInstance(search.queries[0], SearchQuery)
|
||||||
|
self.assertEqual("redmine", search.queries[0].source)
|
||||||
|
self.assertEqual("hiring", search.queries[0].project_identifier)
|
||||||
|
self.assertEqual({"id": "doc1", "text": "Snippet"}, document)
|
||||||
|
self.assertEqual("customer-service", projects["projects"][0]["project_identifier"])
|
||||||
|
self.assertTrue(refresh_response["dry_run"])
|
||||||
|
self.assertEqual(({"customer-service": 5}, True, False, 15), refresh.calls[0])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from semantic_index.config import load_settings
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticIndexCliTest(unittest.TestCase):
|
||||||
|
def test_help_does_not_require_http_runtime_dependencies(self):
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "semantic_index", "--help"],
|
||||||
|
check=False,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual("", result.stderr)
|
||||||
|
self.assertEqual(0, result.returncode)
|
||||||
|
self.assertIn("--mcp-stdio", result.stdout)
|
||||||
|
|
||||||
|
def test_settings_load_from_package_env_when_root_env_missing(self):
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
env_path = Path(tmp) / "semantic_index" / ".env"
|
||||||
|
env_path.parent.mkdir()
|
||||||
|
env_path.write_text("QDRANT_URL=http://qdrant.example:6333\nREDMINE_SAMPLE_LIMIT=7\n", encoding="utf-8")
|
||||||
|
|
||||||
|
settings = load_settings(Path(tmp) / ".env")
|
||||||
|
|
||||||
|
self.assertEqual("http://qdrant.example:6333", settings.qdrant_url)
|
||||||
|
self.assertEqual(7, settings.sample_limit)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from semantic_index.client import SemanticIndexClient
|
||||||
|
from semantic_index.models import SearchResult
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSearchService:
|
||||||
|
def __init__(self):
|
||||||
|
self.queries = []
|
||||||
|
|
||||||
|
def search(self, query):
|
||||||
|
self.queries.append(query)
|
||||||
|
return [
|
||||||
|
SearchResult(
|
||||||
|
id="redmine:issue:1:chunk:0",
|
||||||
|
score=0.7,
|
||||||
|
text="Candidate follow up",
|
||||||
|
payload={
|
||||||
|
"source": "redmine",
|
||||||
|
"project_identifier": "hiring",
|
||||||
|
"doc_type": "issue",
|
||||||
|
"issue_id": 1,
|
||||||
|
"redmine_url": "http://redmine/issues/1",
|
||||||
|
"source_record_id": "issue:1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_document(self, document_id):
|
||||||
|
return {"id": document_id, "text": "Full text", "payload": {"project_identifier": "hiring"}}
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticIndexClientTest(unittest.TestCase):
|
||||||
|
def test_in_process_client_returns_normalized_search_response(self):
|
||||||
|
search = FakeSearchService()
|
||||||
|
client = SemanticIndexClient(search_service=search)
|
||||||
|
|
||||||
|
response = client.search("candidate follow up", project_identifier="hiring", limit=3)
|
||||||
|
|
||||||
|
self.assertEqual("candidate follow up", response["query"])
|
||||||
|
self.assertEqual({"project_identifier": "hiring", "limit": 3}, response["filters"])
|
||||||
|
self.assertEqual("redmine:issue:1:chunk:0", response["results"][0]["id"])
|
||||||
|
self.assertEqual("hiring", response["results"][0]["citation"]["project_identifier"])
|
||||||
|
self.assertEqual("hiring", search.queries[0].project_identifier)
|
||||||
|
|
||||||
|
def test_in_process_client_get_document(self):
|
||||||
|
client = SemanticIndexClient(search_service=FakeSearchService())
|
||||||
|
|
||||||
|
document = client.get_document("redmine:issue:1:chunk:0")
|
||||||
|
|
||||||
|
self.assertEqual("Full text", document["text"])
|
||||||
|
|
||||||
|
def test_http_client_sends_auth_header_and_parses_search_response(self):
|
||||||
|
body = json.dumps({"query": "printer", "filters": {}, "results": []}).encode()
|
||||||
|
|
||||||
|
class FakeResponse:
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc, tb):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
return body
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
def fake_urlopen(request, timeout):
|
||||||
|
captured["url"] = request.full_url
|
||||||
|
captured["authorization"] = request.headers.get("Authorization")
|
||||||
|
captured["body"] = json.loads(request.data.decode())
|
||||||
|
return FakeResponse()
|
||||||
|
|
||||||
|
with patch("urllib.request.urlopen", fake_urlopen):
|
||||||
|
client = SemanticIndexClient(base_url="http://semantic.local", api_key="secret")
|
||||||
|
response = client.search("printer", project_identifier="customer-service")
|
||||||
|
|
||||||
|
self.assertEqual("http://semantic.local/search", captured["url"])
|
||||||
|
self.assertEqual("Bearer secret", captured["authorization"])
|
||||||
|
self.assertEqual("customer-service", captured["body"]["project_identifier"])
|
||||||
|
self.assertEqual("printer", response["query"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from semantic_index.models import IndexDocument
|
||||||
|
from semantic_index.redmine import RedmineMapper
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineMapperTest(unittest.TestCase):
|
||||||
|
def test_issue_chunks_have_stable_ids_and_metadata(self):
|
||||||
|
issue = {
|
||||||
|
"id": 42,
|
||||||
|
"subject": "Widget order ORD-12345 cannot ship",
|
||||||
|
"description": "Customer reports that widget order ORD-12345 is blocked.",
|
||||||
|
"project": {"id": 7, "identifier": "fud-helpdesk"},
|
||||||
|
"contact": {"id": 9, "email": "ada@example.com", "name": "Ada Lovelace"},
|
||||||
|
"created_on": "2026-04-01T10:00:00Z",
|
||||||
|
"updated_on": "2026-04-02T10:00:00Z",
|
||||||
|
"url": "http://redmine.local/issues/42",
|
||||||
|
}
|
||||||
|
|
||||||
|
first = RedmineMapper(redmine_url="http://redmine.local").issue_to_documents(issue)
|
||||||
|
second = RedmineMapper(redmine_url="http://redmine.local").issue_to_documents(issue)
|
||||||
|
|
||||||
|
self.assertEqual([doc.id for doc in first], [doc.id for doc in second])
|
||||||
|
self.assertEqual("redmine:issue:42:chunk:0", first[0].id)
|
||||||
|
self.assertEqual("issue", first[0].payload["doc_type"])
|
||||||
|
self.assertEqual(42, first[0].payload["issue_id"])
|
||||||
|
self.assertEqual("fud-helpdesk", first[0].payload["project_identifier"])
|
||||||
|
self.assertIsNone(first[0].payload["project_name"])
|
||||||
|
self.assertFalse(first[0].payload["has_helpdesk_ticket"])
|
||||||
|
self.assertEqual("ada@example.com", first[0].payload["contact_email"])
|
||||||
|
self.assertEqual("Ada Lovelace", first[0].payload["contact_name"])
|
||||||
|
self.assertEqual("http://redmine.local/issues/42", first[0].payload["redmine_url"])
|
||||||
|
self.assertIn("source_hash", first[0].payload)
|
||||||
|
|
||||||
|
def test_helpdesk_ticket_contact_is_mapped_to_all_issue_chunks(self):
|
||||||
|
issue = {
|
||||||
|
"id": 39779,
|
||||||
|
"subject": "Goods return",
|
||||||
|
"description": "Please arrange to return these goods.",
|
||||||
|
"project": {"id": 1, "identifier": "customer-service"},
|
||||||
|
"helpdesk_ticket": {
|
||||||
|
"id": 35159,
|
||||||
|
"contact_id": 1890,
|
||||||
|
"from_address": "callum@safetagtracking.com",
|
||||||
|
"contact": {
|
||||||
|
"id": 1890,
|
||||||
|
"name": "Callum Mackeonis",
|
||||||
|
"company": "SafeTag Tracking",
|
||||||
|
"email": "callum@safetagtracking.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"journals": [
|
||||||
|
{"id": 71570, "notes": "Hello, yes we can arrange this today.", "created_on": "2026-04-14T14:29:49Z"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
docs = RedmineMapper(redmine_url="http://redmine.local").issue_to_documents(issue)
|
||||||
|
issue_doc = next(doc for doc in docs if doc.payload["doc_type"] == "issue")
|
||||||
|
journal_doc = next(doc for doc in docs if doc.payload["doc_type"] == "journal")
|
||||||
|
contact_doc = next(doc for doc in docs if doc.payload["doc_type"] == "contact")
|
||||||
|
|
||||||
|
for doc in (issue_doc, journal_doc, contact_doc):
|
||||||
|
self.assertEqual(35159, doc.payload["helpdesk_ticket_id"])
|
||||||
|
self.assertTrue(doc.payload["has_helpdesk_ticket"])
|
||||||
|
self.assertEqual(1890, doc.payload["contact_id"])
|
||||||
|
self.assertEqual("Callum Mackeonis", doc.payload["contact_name"])
|
||||||
|
self.assertEqual("SafeTag Tracking", doc.payload["contact_company"])
|
||||||
|
self.assertEqual("callum@safetagtracking.com", doc.payload["contact_email"])
|
||||||
|
self.assertIn("Callum Mackeonis", issue_doc.text)
|
||||||
|
self.assertIn("callum@safetagtracking.com", contact_doc.text)
|
||||||
|
|
||||||
|
def test_configured_project_identifier_is_used_when_issue_payload_omits_identifier(self):
|
||||||
|
issue = {
|
||||||
|
"id": 42,
|
||||||
|
"subject": "Widget order",
|
||||||
|
"description": "Body",
|
||||||
|
"project": {"id": 1, "name": "Customer Service"},
|
||||||
|
}
|
||||||
|
|
||||||
|
docs = RedmineMapper(
|
||||||
|
redmine_url="http://redmine.local",
|
||||||
|
project_identifier="customer-service",
|
||||||
|
).issue_to_documents(issue)
|
||||||
|
|
||||||
|
self.assertEqual("customer-service", docs[0].payload["project_identifier"])
|
||||||
|
self.assertEqual("Customer Service", docs[0].payload["project_name"])
|
||||||
|
|
||||||
|
def test_internal_non_helpdesk_issue_keeps_project_metadata_without_contact(self):
|
||||||
|
issue = {
|
||||||
|
"id": 55,
|
||||||
|
"subject": "Internal hiring task",
|
||||||
|
"description": "Follow up with candidate.",
|
||||||
|
"project": {"id": 68, "identifier": "hiring", "name": "Hiring"},
|
||||||
|
}
|
||||||
|
|
||||||
|
docs = RedmineMapper(redmine_url="http://redmine.local").issue_to_documents(issue)
|
||||||
|
|
||||||
|
self.assertEqual(1, len(docs))
|
||||||
|
self.assertEqual("hiring", docs[0].payload["project_identifier"])
|
||||||
|
self.assertEqual("Hiring", docs[0].payload["project_name"])
|
||||||
|
self.assertFalse(docs[0].payload["has_helpdesk_ticket"])
|
||||||
|
self.assertIsNone(docs[0].payload["contact_id"])
|
||||||
|
|
||||||
|
def test_issue_journals_messages_and_contact_are_mapped(self):
|
||||||
|
issue = {
|
||||||
|
"id": 42,
|
||||||
|
"subject": "Widget order",
|
||||||
|
"description": "Ticket envelope",
|
||||||
|
"project": {"id": 7, "identifier": "fud-helpdesk"},
|
||||||
|
"contact": {"id": 9, "email": "ada@example.com", "name": "Ada Lovelace"},
|
||||||
|
"journals": [
|
||||||
|
{"id": 5, "notes": "Private escalation note", "private_notes": True, "created_on": "2026-04-03T10:00:00Z"}
|
||||||
|
],
|
||||||
|
"messages": [
|
||||||
|
{"id": 6, "body": "Customer reply body", "direction": "incoming", "created_on": "2026-04-03T11:00:00Z"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
docs = RedmineMapper(redmine_url="http://redmine.local").issue_to_documents(issue)
|
||||||
|
ids = {doc.id for doc in docs}
|
||||||
|
types = {doc.payload["doc_type"] for doc in docs}
|
||||||
|
|
||||||
|
self.assertIn("redmine:issue:42:journal:5:chunk:0", ids)
|
||||||
|
self.assertIn("redmine:issue:42:message:6:chunk:0", ids)
|
||||||
|
self.assertIn("redmine:contact:9:issue:42:chunk:0", ids)
|
||||||
|
self.assertEqual({"issue", "journal", "message", "contact"}, types)
|
||||||
|
journal = next(doc for doc in docs if doc.payload["doc_type"] == "journal")
|
||||||
|
message = next(doc for doc in docs if doc.payload["doc_type"] == "message")
|
||||||
|
self.assertEqual("private", journal.payload["visibility"])
|
||||||
|
self.assertEqual("incoming", message.payload["direction"])
|
||||||
|
|
||||||
|
def test_empty_documents_are_rejected(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
IndexDocument(id="x", text=" ", payload={})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from semantic_index.embeddings import OpenAIEmbedder
|
||||||
|
from semantic_index.models import IndexDocument
|
||||||
|
|
||||||
|
|
||||||
|
class FakeOpenAIClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def create_embeddings(self, model, inputs, dimensions=None):
|
||||||
|
self.calls.append({"model": model, "inputs": list(inputs), "dimensions": dimensions})
|
||||||
|
return [[float(i)] * 3 for i, _ in enumerate(inputs, start=1)]
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIEmbedderTest(unittest.TestCase):
|
||||||
|
def test_batches_embedding_requests(self):
|
||||||
|
client = FakeOpenAIClient()
|
||||||
|
embedder = OpenAIEmbedder(client=client, batch_size=2, dimensions=1536)
|
||||||
|
docs = [
|
||||||
|
IndexDocument(id="a", text="alpha", payload={}),
|
||||||
|
IndexDocument(id="b", text="bravo", payload={}),
|
||||||
|
IndexDocument(id="c", text="charlie", payload={}),
|
||||||
|
]
|
||||||
|
|
||||||
|
vectors = embedder.embed_documents(docs)
|
||||||
|
|
||||||
|
self.assertEqual([[1.0, 1.0, 1.0], [2.0, 2.0, 2.0], [1.0, 1.0, 1.0]], vectors)
|
||||||
|
self.assertEqual(2, len(client.calls))
|
||||||
|
self.assertEqual(["alpha", "bravo"], client.calls[0]["inputs"])
|
||||||
|
self.assertEqual("text-embedding-3-small", client.calls[0]["model"])
|
||||||
|
self.assertEqual(1536, client.calls[0]["dimensions"])
|
||||||
|
|
||||||
|
def test_rejects_empty_or_oversized_chunks_before_api_call(self):
|
||||||
|
client = FakeOpenAIClient()
|
||||||
|
embedder = OpenAIEmbedder(client=client, max_chars=5)
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
embedder.embed_texts(["ok", " "])
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
embedder.embed_texts(["toolong"])
|
||||||
|
self.assertEqual([], client.calls)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from semantic_index.__main__ import main
|
||||||
|
from semantic_index.config import Settings
|
||||||
|
from semantic_index.models import SearchResult
|
||||||
|
|
||||||
|
|
||||||
|
class FakeSearchService:
|
||||||
|
def __init__(self):
|
||||||
|
self.queries = []
|
||||||
|
|
||||||
|
def search(self, query):
|
||||||
|
self.queries.append(query)
|
||||||
|
if "missing@example.test" in query.text:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
SearchResult(
|
||||||
|
id="redmine:contact:1890:issue:39779:chunk:0" if "callum" in query.text else "redmine:issue:39779:chunk:0",
|
||||||
|
score=0.58,
|
||||||
|
text="Callum Mackeonis callum@safetagtracking.com SafeTag Tracking",
|
||||||
|
payload={
|
||||||
|
"source": "redmine",
|
||||||
|
"doc_type": "contact" if "callum" in query.text else "issue",
|
||||||
|
"issue_id": 39779,
|
||||||
|
"project_identifier": "customer-service",
|
||||||
|
"contact_id": 1890,
|
||||||
|
"contact_name": "Callum Mackeonis",
|
||||||
|
"contact_email": "callum@safetagtracking.com",
|
||||||
|
"contact_company": "SafeTag Tracking",
|
||||||
|
"redmine_url": "http://redmine/issues/39779",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_document(self, document_id):
|
||||||
|
return {
|
||||||
|
"id": document_id,
|
||||||
|
"text": "Full indexed text",
|
||||||
|
"payload": {
|
||||||
|
"source": "redmine",
|
||||||
|
"doc_type": "journal",
|
||||||
|
"issue_id": 39778,
|
||||||
|
"project_identifier": "customer-service",
|
||||||
|
"contact_id": 1890,
|
||||||
|
"contact_name": "Callum Mackeonis",
|
||||||
|
"contact_email": "callum@safetagtracking.com",
|
||||||
|
"redmine_url": "http://redmine/issues/39778",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
def __init__(self):
|
||||||
|
self.list_limits = []
|
||||||
|
|
||||||
|
def count_documents(self, source=None, project_identifier=None, doc_type=None):
|
||||||
|
return 12
|
||||||
|
|
||||||
|
def list_documents(self, limit=10, source=None, project_identifier=None, doc_type=None):
|
||||||
|
self.list_limits.append(limit)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": "redmine:issue:39779:chunk:0",
|
||||||
|
"text": "Issue #39779: Goods return\nPlease return our goods.",
|
||||||
|
"payload": {
|
||||||
|
"source": "redmine",
|
||||||
|
"doc_type": "issue",
|
||||||
|
"issue_id": 39779,
|
||||||
|
"project_identifier": "customer-service",
|
||||||
|
"project_name": "Customer Service",
|
||||||
|
"has_helpdesk_ticket": True,
|
||||||
|
"contact_id": 1890,
|
||||||
|
"contact_name": "Callum Mackeonis",
|
||||||
|
"contact_email": "callum@safetagtracking.com",
|
||||||
|
"contact_company": "SafeTag Tracking",
|
||||||
|
"source_hash": "issue-hash",
|
||||||
|
"redmine_url": "http://redmine/issues/39779",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "redmine:issue:39779:journal:71570:chunk:0",
|
||||||
|
"text": "Hello, we can arrange this today.",
|
||||||
|
"payload": {
|
||||||
|
"source": "redmine",
|
||||||
|
"doc_type": "journal",
|
||||||
|
"issue_id": 39779,
|
||||||
|
"project_identifier": "customer-service",
|
||||||
|
"project_name": "Customer Service",
|
||||||
|
"has_helpdesk_ticket": True,
|
||||||
|
"contact_id": 1890,
|
||||||
|
"contact_name": "Callum Mackeonis",
|
||||||
|
"contact_email": "callum@safetagtracking.com",
|
||||||
|
"contact_company": "SafeTag Tracking",
|
||||||
|
"source_hash": "journal-hash",
|
||||||
|
"redmine_url": "http://redmine/issues/39779",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "redmine:contact:1890:issue:39779:chunk:0",
|
||||||
|
"text": "Callum Mackeonis callum@safetagtracking.com SafeTag Tracking",
|
||||||
|
"payload": {
|
||||||
|
"source": "redmine",
|
||||||
|
"doc_type": "contact",
|
||||||
|
"issue_id": 39779,
|
||||||
|
"project_identifier": "customer-service",
|
||||||
|
"project_name": "Customer Service",
|
||||||
|
"has_helpdesk_ticket": True,
|
||||||
|
"contact_id": 1890,
|
||||||
|
"contact_name": "Callum Mackeonis",
|
||||||
|
"contact_email": "callum@safetagtracking.com",
|
||||||
|
"contact_company": "SafeTag Tracking",
|
||||||
|
"source_hash": "contact-hash",
|
||||||
|
"redmine_url": "http://redmine/issues/39779",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "redmine:issue:39800:chunk:0",
|
||||||
|
"text": "Ordinary issue with no helpdesk contact.",
|
||||||
|
"payload": {
|
||||||
|
"source": "redmine",
|
||||||
|
"doc_type": "issue",
|
||||||
|
"issue_id": 39800,
|
||||||
|
"project_identifier": "hiring",
|
||||||
|
"project_name": "Hiring",
|
||||||
|
"has_helpdesk_ticket": False,
|
||||||
|
"source_hash": "ordinary-hash",
|
||||||
|
"redmine_url": "http://redmine/issues/39800",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRedmineSource:
|
||||||
|
def recent_helpdesk_issues(self, limit):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": 39779,
|
||||||
|
"subject": "Goods return",
|
||||||
|
"description": "Please return our goods.",
|
||||||
|
"project": {"id": 1, "identifier": "customer-service"},
|
||||||
|
"helpdesk_ticket": {
|
||||||
|
"id": 35159,
|
||||||
|
"contact_id": 1890,
|
||||||
|
"contact": {
|
||||||
|
"id": 1890,
|
||||||
|
"name": "Callum Mackeonis",
|
||||||
|
"email": "callum@safetagtracking.com",
|
||||||
|
"company": "SafeTag Tracking",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
][:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def fake_services(store=None, search=None):
|
||||||
|
settings = Settings(
|
||||||
|
openai_api_key="",
|
||||||
|
qdrant_url="http://qdrant",
|
||||||
|
qdrant_api_key=None,
|
||||||
|
qdrant_collection="semantic",
|
||||||
|
redmine_url="http://redmine",
|
||||||
|
redmine_api_key="",
|
||||||
|
redmine_project_identifier="customer-service",
|
||||||
|
sample_limit=50,
|
||||||
|
bind_host="127.0.0.1",
|
||||||
|
bind_port=8787,
|
||||||
|
service_api_key=None,
|
||||||
|
refresh_state_path=Path(".cache/semantic_index/refresh_state.json"),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"settings": settings,
|
||||||
|
"search": search or FakeSearchService(),
|
||||||
|
"store": store or FakeStore(),
|
||||||
|
"redmine_source": FakeRedmineSource(),
|
||||||
|
"backfill": FakeBackfillService(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeBackfillService:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def backfill_redmine_sample(self, limit):
|
||||||
|
self.calls.append(("sample", limit))
|
||||||
|
return {"source": "redmine", "issues": limit, "documents": limit}
|
||||||
|
|
||||||
|
def backfill_redmine_projects(self, projects, per_project_limit):
|
||||||
|
self.calls.append(("projects", projects, per_project_limit))
|
||||||
|
return {
|
||||||
|
"source": "redmine",
|
||||||
|
"projects": len(projects),
|
||||||
|
"issues": len(projects) * per_project_limit,
|
||||||
|
"documents": len(projects) * per_project_limit,
|
||||||
|
"project_results": [
|
||||||
|
{"project_identifier": project, "issues": per_project_limit, "documents": per_project_limit}
|
||||||
|
for project in projects
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def backfill_redmine_project_limits(self, project_limits):
|
||||||
|
self.calls.append(("project_limits", project_limits))
|
||||||
|
return {
|
||||||
|
"source": "redmine",
|
||||||
|
"projects": len(project_limits),
|
||||||
|
"issues": sum(project_limits.values()),
|
||||||
|
"documents": sum(project_limits.values()),
|
||||||
|
"project_results": [
|
||||||
|
{"project_identifier": project, "issues": limit, "documents": limit}
|
||||||
|
for project, limit in project_limits.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class InspectCliTest(unittest.TestCase):
|
||||||
|
def run_cli(self, args):
|
||||||
|
out = io.StringIO()
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main(args, service_builder=fake_services)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
def test_no_args_prints_help_without_building_services(self):
|
||||||
|
def broken_services():
|
||||||
|
raise AssertionError("help should not build live services")
|
||||||
|
|
||||||
|
out = io.StringIO()
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main([], service_builder=broken_services)
|
||||||
|
|
||||||
|
self.assertIn("inspect", out.getvalue())
|
||||||
|
|
||||||
|
def test_count_lists_matching_document_count(self):
|
||||||
|
output = self.run_cli(["inspect", "count", "--source", "redmine", "--project", "customer-service"])
|
||||||
|
|
||||||
|
self.assertIn("12", output)
|
||||||
|
|
||||||
|
def test_list_shows_snippet_and_metadata_by_default(self):
|
||||||
|
output = self.run_cli(["inspect", "list", "--limit", "5", "--source", "redmine", "--project", "customer-service"])
|
||||||
|
|
||||||
|
self.assertIn("redmine:issue:39779:chunk:0", output)
|
||||||
|
self.assertIn("issue #39779", output.lower())
|
||||||
|
self.assertIn("customer-service", output)
|
||||||
|
self.assertIn("contact=#1890", output)
|
||||||
|
self.assertIn("Callum Mackeonis", output)
|
||||||
|
self.assertIn("callum@safetagtracking.com", output)
|
||||||
|
self.assertNotIn("Full indexed text", output)
|
||||||
|
|
||||||
|
def test_search_runs_query_and_prints_citation(self):
|
||||||
|
output = self.run_cli(["inspect", "search", "order status", "--limit", "3", "--project", "customer-service"])
|
||||||
|
|
||||||
|
self.assertIn("score=0.5800", output)
|
||||||
|
self.assertIn("http://redmine/issues/39779", output)
|
||||||
|
|
||||||
|
def test_show_prints_full_document_text(self):
|
||||||
|
output = self.run_cli(["inspect", "show", "redmine:issue:39778:chunk:0"])
|
||||||
|
|
||||||
|
self.assertIn("Full indexed text", output)
|
||||||
|
self.assertIn("doc_type=journal", output)
|
||||||
|
|
||||||
|
def test_preview_redmine_maps_documents_without_writing(self):
|
||||||
|
output = self.run_cli(["inspect", "preview-redmine", "--limit", "1", "--project", "customer-service"])
|
||||||
|
|
||||||
|
self.assertIn("redmine:issue:39779:chunk:0", output)
|
||||||
|
self.assertIn("project=customer-service", output)
|
||||||
|
self.assertIn("Please return our goods", output)
|
||||||
|
|
||||||
|
def test_preview_redmine_uses_minimal_service_builder(self):
|
||||||
|
services = []
|
||||||
|
|
||||||
|
def minimal_builder(settings):
|
||||||
|
services.append(settings.redmine_project_identifier)
|
||||||
|
return {"settings": settings, "redmine_source": FakeRedmineSource()}
|
||||||
|
|
||||||
|
out = io.StringIO()
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main(
|
||||||
|
["inspect", "preview-redmine", "--limit", "1", "--project", "customer-service"],
|
||||||
|
service_builder=lambda: (_ for _ in ()).throw(AssertionError("full services should not be built")),
|
||||||
|
preview_service_builder=minimal_builder,
|
||||||
|
settings_loader=lambda: fake_services()["settings"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(["customer-service"], services)
|
||||||
|
self.assertIn("redmine:issue:39779:chunk:0", out.getvalue())
|
||||||
|
|
||||||
|
def test_audit_prints_doc_type_counts_contact_coverage_and_attachment_check(self):
|
||||||
|
output = self.run_cli(["inspect", "audit", "--limit", "10", "--source", "redmine", "--project", "customer-service"])
|
||||||
|
|
||||||
|
self.assertIn("documents=4", output)
|
||||||
|
self.assertIn("doc_type issue=2", output)
|
||||||
|
self.assertIn("doc_type journal=1", output)
|
||||||
|
self.assertIn("doc_type contact=1", output)
|
||||||
|
self.assertIn("contact_metadata 3/4", output)
|
||||||
|
self.assertIn("helpdesk_contact_metadata 3/3", output)
|
||||||
|
self.assertIn("project customer-service=3", output)
|
||||||
|
self.assertIn("project hiring=1", output)
|
||||||
|
self.assertIn("attachments=0", output)
|
||||||
|
self.assertNotIn("missing_contact redmine:issue:39800:chunk:0", output)
|
||||||
|
|
||||||
|
def test_audit_json_returns_machine_readable_summary(self):
|
||||||
|
output = self.run_cli(["inspect", "audit", "--limit", "10", "--project", "customer-service", "--json"])
|
||||||
|
payload = json.loads(output)
|
||||||
|
|
||||||
|
self.assertEqual(4, payload["total_documents"])
|
||||||
|
self.assertEqual(2, payload["doc_type_counts"]["issue"])
|
||||||
|
self.assertEqual(3, payload["project_counts"]["customer-service"])
|
||||||
|
self.assertEqual(1, payload["project_counts"]["hiring"])
|
||||||
|
self.assertEqual([], payload["missing_helpdesk_contact_metadata"])
|
||||||
|
|
||||||
|
def test_compare_redmine_reports_missing_stale_and_contact_mismatches(self):
|
||||||
|
output = self.run_cli(["inspect", "compare-redmine", "--limit", "1", "--project", "customer-service"])
|
||||||
|
|
||||||
|
self.assertIn("preview_documents=2", output)
|
||||||
|
self.assertIn("indexed_documents=4", output)
|
||||||
|
self.assertIn("stale", output)
|
||||||
|
self.assertIn("redmine:issue:39779:chunk:0", output)
|
||||||
|
|
||||||
|
def test_compare_redmine_fetches_a_large_index_window_to_avoid_false_missing_results(self):
|
||||||
|
store = FakeStore()
|
||||||
|
out = io.StringIO()
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main(["inspect", "compare-redmine", "--limit", "3", "--project", "customer-service"], service_builder=lambda: fake_services(store=store))
|
||||||
|
|
||||||
|
self.assertEqual(5000, store.list_limits[0])
|
||||||
|
|
||||||
|
def test_smoke_search_prints_pass_fail_for_known_queries(self):
|
||||||
|
output = self.run_cli(["inspect", "smoke-search", "--project", "customer-service", "--email", "callum@safetagtracking.com", "--issue-id", "39779"])
|
||||||
|
|
||||||
|
self.assertIn("PASS email callum@safetagtracking.com", output)
|
||||||
|
self.assertIn("PASS issue 39779", output)
|
||||||
|
self.assertIn("redmine:contact:1890:issue:39779:chunk:0", output)
|
||||||
|
|
||||||
|
def test_smoke_search_uses_issue_id_filter_for_issue_checks(self):
|
||||||
|
search = FakeSearchService()
|
||||||
|
out = io.StringIO()
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main(["inspect", "smoke-search", "--project", "customer-service", "--issue-id", "39779"], service_builder=lambda: fake_services(search=search))
|
||||||
|
|
||||||
|
issue_queries = [query for query in search.queries if query.text == "39779"]
|
||||||
|
self.assertEqual(39779, issue_queries[0].issue_id)
|
||||||
|
|
||||||
|
def test_smoke_search_json_returns_check_results(self):
|
||||||
|
output = self.run_cli(["inspect", "smoke-search", "--project", "customer-service", "--email", "missing@example.test", "--json"])
|
||||||
|
payload = json.loads(output)
|
||||||
|
|
||||||
|
self.assertFalse(payload["checks"][0]["passed"])
|
||||||
|
self.assertEqual("email", payload["checks"][0]["kind"])
|
||||||
|
|
||||||
|
def test_backfill_redmine_projects_cli_parses_comma_separated_projects(self):
|
||||||
|
backfill = FakeBackfillService()
|
||||||
|
services = fake_services()
|
||||||
|
services["backfill"] = backfill
|
||||||
|
out = io.StringIO()
|
||||||
|
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main(
|
||||||
|
[
|
||||||
|
"--backfill-redmine-projects",
|
||||||
|
"--projects",
|
||||||
|
"customer-service,hiring",
|
||||||
|
"--per-project-limit",
|
||||||
|
"25",
|
||||||
|
],
|
||||||
|
service_builder=lambda: services,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(("projects", ["customer-service", "hiring"], 25), backfill.calls[0])
|
||||||
|
self.assertIn("'projects': 2", out.getvalue())
|
||||||
|
|
||||||
|
def test_backfill_redmine_projects_cli_parses_project_specific_limits(self):
|
||||||
|
backfill = FakeBackfillService()
|
||||||
|
services = fake_services()
|
||||||
|
services["backfill"] = backfill
|
||||||
|
out = io.StringIO()
|
||||||
|
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main(
|
||||||
|
[
|
||||||
|
"--backfill-redmine-projects",
|
||||||
|
"--project-limits",
|
||||||
|
"customer-service=500,hiring=200",
|
||||||
|
],
|
||||||
|
service_builder=lambda: services,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(("project_limits", {"customer-service": 500, "hiring": 200}), backfill.calls[0])
|
||||||
|
self.assertIn("'issues': 700", out.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
INSTALLER = ROOT / "deploy" / "semantic-index" / "install.sh"
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticIndexInstallerTest(unittest.TestCase):
|
||||||
|
def run_installer(self, *args, env=None):
|
||||||
|
return subprocess.run(
|
||||||
|
[str(INSTALLER), *args],
|
||||||
|
cwd=ROOT,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
check=False,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_default_mode_is_dry_run(self):
|
||||||
|
result = self.run_installer()
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
self.assertIn("mode=dry-run", result.stdout)
|
||||||
|
self.assertIn("would run: sudo mkdir -p /opt/semantic-index", result.stdout)
|
||||||
|
self.assertIn("would run: sudo rsync", result.stdout)
|
||||||
|
self.assertNotIn("Semantic Index installed, but deployment is not complete.", result.stdout)
|
||||||
|
|
||||||
|
def test_apply_prints_manual_next_step_warning(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
tmp_path = Path(tmp)
|
||||||
|
env = {
|
||||||
|
"PATH": "/usr/bin:/bin",
|
||||||
|
"SEMANTIC_INDEX_INSTALL_DIR": str(tmp_path / "opt" / "semantic-index"),
|
||||||
|
"SEMANTIC_INDEX_ENV_FILE": str(tmp_path / "etc" / "semantic-index.env"),
|
||||||
|
"SEMANTIC_INDEX_STATE_DIR": str(tmp_path / "var" / "lib" / "semantic-index"),
|
||||||
|
"SEMANTIC_INDEX_LOG_DIR": str(tmp_path / "var" / "log" / "semantic-index"),
|
||||||
|
"SEMANTIC_INDEX_SYSTEMD_DIR": str(tmp_path / "etc" / "systemd" / "system"),
|
||||||
|
}
|
||||||
|
result = self.run_installer("--apply", "--no-system", "--skip-deps", env=env)
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
self.assertIn("Semantic Index installed, but deployment is not complete.", result.stdout)
|
||||||
|
self.assertIn("The refresh timer was NOT enabled automatically.", result.stdout)
|
||||||
|
self.assertIn("Do not use --force-rebuild", result.stdout)
|
||||||
|
|
||||||
|
def test_invalid_argument_fails_with_usage(self):
|
||||||
|
result = self.run_installer("--force-rebuild")
|
||||||
|
|
||||||
|
self.assertEqual(2, result.returncode)
|
||||||
|
self.assertIn("Usage:", result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from semantic_index.models import IndexDocument
|
||||||
|
from semantic_index.qdrant_store import QdrantStore
|
||||||
|
|
||||||
|
|
||||||
|
class FakeMatchValue:
|
||||||
|
def __init__(self, value):
|
||||||
|
self.value = value
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFieldCondition:
|
||||||
|
def __init__(self, key, match=None, range=None):
|
||||||
|
self.key = key
|
||||||
|
self.match = match
|
||||||
|
self.range = range
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFilter:
|
||||||
|
def __init__(self, must):
|
||||||
|
self.must = must
|
||||||
|
|
||||||
|
|
||||||
|
class FakeFilterSelector:
|
||||||
|
def __init__(self, filter):
|
||||||
|
self.filter = filter
|
||||||
|
|
||||||
|
|
||||||
|
class FakePointIdsList:
|
||||||
|
def __init__(self, points):
|
||||||
|
self.points = points
|
||||||
|
|
||||||
|
|
||||||
|
class FakeQModels:
|
||||||
|
MatchValue = FakeMatchValue
|
||||||
|
FieldCondition = FakeFieldCondition
|
||||||
|
Filter = FakeFilter
|
||||||
|
FilterSelector = FakeFilterSelector
|
||||||
|
PointIdsList = FakePointIdsList
|
||||||
|
|
||||||
|
class PointStruct:
|
||||||
|
def __init__(self, id, vector, payload):
|
||||||
|
self.id = id
|
||||||
|
self.vector = vector
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCountResult:
|
||||||
|
count = 7
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRecord:
|
||||||
|
def __init__(self):
|
||||||
|
self.id = "point-id"
|
||||||
|
self.payload = {
|
||||||
|
"document_id": "redmine:issue:1:chunk:0",
|
||||||
|
"text": "Indexed text",
|
||||||
|
"source": "redmine",
|
||||||
|
"project_identifier": "customer-service",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.count_filter = None
|
||||||
|
self.scroll_filter = None
|
||||||
|
self.delete_filter = None
|
||||||
|
self.delete_selector = None
|
||||||
|
self.upsert_batches = []
|
||||||
|
|
||||||
|
def get_collections(self):
|
||||||
|
collection = type("Collection", (), {"name": "semantic"})()
|
||||||
|
return type("Collections", (), {"collections": [collection]})()
|
||||||
|
|
||||||
|
def count(self, collection_name, count_filter, exact):
|
||||||
|
self.count_filter = count_filter
|
||||||
|
return FakeCountResult()
|
||||||
|
|
||||||
|
def scroll(self, collection_name, scroll_filter, limit, with_payload, with_vectors, offset=None):
|
||||||
|
self.scroll_filter = scroll_filter
|
||||||
|
return [FakeRecord()], None
|
||||||
|
|
||||||
|
def delete(self, collection_name, points_selector):
|
||||||
|
self.delete_selector = points_selector
|
||||||
|
self.delete_filter = getattr(points_selector, "filter", None)
|
||||||
|
|
||||||
|
def upsert(self, collection_name, points):
|
||||||
|
self.upsert_batches.append(points)
|
||||||
|
|
||||||
|
|
||||||
|
class QdrantStoreReadTest(unittest.TestCase):
|
||||||
|
def make_store(self):
|
||||||
|
store = object.__new__(QdrantStore)
|
||||||
|
store.client = FakeClient()
|
||||||
|
store.collection = "semantic"
|
||||||
|
store.vector_size = 1536
|
||||||
|
store.qmodels = FakeQModels
|
||||||
|
store.upsert_batch_size = 2
|
||||||
|
return store
|
||||||
|
|
||||||
|
def test_count_documents_builds_metadata_filter(self):
|
||||||
|
store = self.make_store()
|
||||||
|
|
||||||
|
count = store.count_documents(source="redmine", project_identifier="customer-service", doc_type="issue")
|
||||||
|
|
||||||
|
self.assertEqual(7, count)
|
||||||
|
conditions = store.client.count_filter.must
|
||||||
|
self.assertEqual(["source", "project_identifier", "doc_type"], [condition.key for condition in conditions])
|
||||||
|
self.assertEqual("customer-service", conditions[1].match.value)
|
||||||
|
|
||||||
|
def test_list_documents_strips_internal_payload_fields(self):
|
||||||
|
store = self.make_store()
|
||||||
|
|
||||||
|
documents = store.list_documents(limit=5, source="redmine", project_identifier="customer-service")
|
||||||
|
|
||||||
|
self.assertEqual("redmine:issue:1:chunk:0", documents[0]["id"])
|
||||||
|
self.assertEqual("Indexed text", documents[0]["text"])
|
||||||
|
self.assertNotIn("document_id", documents[0]["payload"])
|
||||||
|
self.assertNotIn("text", documents[0]["payload"])
|
||||||
|
|
||||||
|
def test_delete_by_source_can_be_limited_to_project_scope(self):
|
||||||
|
store = self.make_store()
|
||||||
|
|
||||||
|
store.delete_by_source("redmine", project_identifier="customer-service")
|
||||||
|
|
||||||
|
conditions = store.client.delete_filter.must
|
||||||
|
self.assertEqual(["source", "project_identifier"], [condition.key for condition in conditions])
|
||||||
|
self.assertEqual("redmine", conditions[0].match.value)
|
||||||
|
self.assertEqual("customer-service", conditions[1].match.value)
|
||||||
|
|
||||||
|
def test_list_documents_can_be_limited_to_issue_scope(self):
|
||||||
|
store = self.make_store()
|
||||||
|
|
||||||
|
store.list_documents(limit=5, source="redmine", project_identifier="customer-service", issue_id=39779)
|
||||||
|
|
||||||
|
conditions = store.client.scroll_filter.must
|
||||||
|
self.assertEqual(["source", "project_identifier", "issue_id"], [condition.key for condition in conditions])
|
||||||
|
self.assertEqual(39779, conditions[2].match.value)
|
||||||
|
|
||||||
|
def test_delete_documents_deletes_stable_document_point_ids(self):
|
||||||
|
store = self.make_store()
|
||||||
|
|
||||||
|
store.delete_documents(["redmine:issue:39779:chunk:0"])
|
||||||
|
|
||||||
|
self.assertEqual(1, len(store.client.delete_selector.points))
|
||||||
|
self.assertNotEqual("redmine:issue:39779:chunk:0", store.client.delete_selector.points[0])
|
||||||
|
|
||||||
|
def test_upsert_sends_points_in_batches(self):
|
||||||
|
store = self.make_store()
|
||||||
|
documents = [
|
||||||
|
IndexDocument(id=f"redmine:issue:{issue_id}:chunk:0", text=f"Issue {issue_id}", payload={"source": "redmine"})
|
||||||
|
for issue_id in range(5)
|
||||||
|
]
|
||||||
|
vectors = [[0.1, 0.2, 0.3] for _ in documents]
|
||||||
|
|
||||||
|
store.upsert(documents, vectors)
|
||||||
|
|
||||||
|
self.assertEqual([2, 2, 1], [len(batch) for batch in store.client.upsert_batches])
|
||||||
|
self.assertEqual("Issue 0", store.client.upsert_batches[0][0].payload["text"])
|
||||||
|
|
||||||
|
def test_list_documents_paginates_qdrant_scroll_until_requested_limit(self):
|
||||||
|
class PagedClient(FakeClient):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.offsets = []
|
||||||
|
|
||||||
|
def scroll(self, collection_name, scroll_filter, limit, with_payload, with_vectors, offset=None):
|
||||||
|
self.offsets.append(offset)
|
||||||
|
first = FakeRecord()
|
||||||
|
first.payload = {**first.payload, "document_id": f"doc:{len(self.offsets)}a"}
|
||||||
|
second = FakeRecord()
|
||||||
|
second.payload = {**second.payload, "document_id": f"doc:{len(self.offsets)}b"}
|
||||||
|
if offset is None:
|
||||||
|
return [first, second], "next"
|
||||||
|
return [first, second], None
|
||||||
|
|
||||||
|
store = self.make_store()
|
||||||
|
store.client = PagedClient()
|
||||||
|
|
||||||
|
documents = store.list_documents(limit=3, source="redmine")
|
||||||
|
|
||||||
|
self.assertEqual(["doc:1a", "doc:1b", "doc:2a"], [document["id"] for document in documents])
|
||||||
|
self.assertEqual([None, "next"], store.client.offsets)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from semantic_index.redmine import RedmineApiSource
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingRedmineSource(RedmineApiSource):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(redmine_url="http://redmine.local", api_key="secret", project_identifier="customer-service")
|
||||||
|
self.urls = []
|
||||||
|
|
||||||
|
def _get_json(self, url):
|
||||||
|
self.urls.append(url)
|
||||||
|
if url.startswith("http://redmine.local/issues.json"):
|
||||||
|
return {"issues": [{"id": 39779}]}
|
||||||
|
return {"issue": {"id": 39779, "subject": "Goods return"}}
|
||||||
|
|
||||||
|
|
||||||
|
class PagedRedmineSource(RedmineApiSource):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(redmine_url="http://redmine.local", api_key="secret", project_identifier="customer-service")
|
||||||
|
self.urls = []
|
||||||
|
|
||||||
|
def _get_json(self, url):
|
||||||
|
self.urls.append(url)
|
||||||
|
if url.startswith("http://redmine.local/issues.json"):
|
||||||
|
query = url.split("?", 1)[1]
|
||||||
|
params = dict(part.split("=", 1) for part in query.split("&"))
|
||||||
|
offset = int(params.get("offset", "0"))
|
||||||
|
limit = int(params.get("limit", "0"))
|
||||||
|
return {"issues": [{"id": issue_id} for issue_id in range(offset + 1, offset + limit + 1)]}
|
||||||
|
issue_id = int(url.split("/issues/", 1)[1].split(".", 1)[0])
|
||||||
|
return {"issue": {"id": issue_id, "subject": f"Issue {issue_id}"}}
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicatePagedRedmineSource(RedmineApiSource):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(redmine_url="http://redmine.local", api_key="secret", project_identifier="customer-service")
|
||||||
|
|
||||||
|
def _get_json(self, url):
|
||||||
|
if url.startswith("http://redmine.local/issues.json"):
|
||||||
|
query = url.split("?", 1)[1]
|
||||||
|
params = dict(part.split("=", 1) for part in query.split("&"))
|
||||||
|
offset = int(params.get("offset", "0"))
|
||||||
|
if offset == 0:
|
||||||
|
return {"issues": [{"id": 1}, {"id": 2}]}
|
||||||
|
if offset == 2:
|
||||||
|
return {"issues": [{"id": 2}, {"id": 3}]}
|
||||||
|
return {"issues": []}
|
||||||
|
issue_id = int(url.split("/issues/", 1)[1].split(".", 1)[0])
|
||||||
|
return {"issue": {"id": issue_id, "subject": f"Issue {issue_id}"}}
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineApiSourceTest(unittest.TestCase):
|
||||||
|
def test_recent_issue_summaries_do_not_fetch_issue_details(self):
|
||||||
|
source = RecordingRedmineSource()
|
||||||
|
|
||||||
|
summaries = list(source.recent_issue_summaries(limit=1))
|
||||||
|
|
||||||
|
self.assertEqual(39779, summaries[0]["id"])
|
||||||
|
self.assertEqual(1, len(source.urls))
|
||||||
|
self.assertTrue(source.urls[0].startswith("http://redmine.local/issues.json"))
|
||||||
|
|
||||||
|
def test_issue_detail_fetches_journals_and_helpdesk(self):
|
||||||
|
source = RecordingRedmineSource()
|
||||||
|
|
||||||
|
detail = source.issue_detail(39779)
|
||||||
|
|
||||||
|
self.assertEqual(39779, detail["id"])
|
||||||
|
self.assertIn("include=journals%2Chelpdesk", source.urls[0])
|
||||||
|
|
||||||
|
def test_recent_helpdesk_issues_requests_helpdesk_include_with_journals(self):
|
||||||
|
source = RecordingRedmineSource()
|
||||||
|
|
||||||
|
issues = list(source.recent_helpdesk_issues(limit=1))
|
||||||
|
|
||||||
|
self.assertEqual(39779, issues[0]["id"])
|
||||||
|
self.assertIn("include=journals%2Chelpdesk", source.urls[1])
|
||||||
|
self.assertIn("subproject_id=%21%2A", source.urls[0])
|
||||||
|
|
||||||
|
def test_recent_helpdesk_issues_paginates_past_redmine_page_limit(self):
|
||||||
|
source = PagedRedmineSource()
|
||||||
|
|
||||||
|
issues = list(source.recent_helpdesk_issues(limit=250))
|
||||||
|
|
||||||
|
self.assertEqual(250, len(issues))
|
||||||
|
list_urls = [url for url in source.urls if url.startswith("http://redmine.local/issues.json")]
|
||||||
|
self.assertEqual(3, len(list_urls))
|
||||||
|
self.assertIn("limit=100", list_urls[0])
|
||||||
|
self.assertIn("offset=0", list_urls[0])
|
||||||
|
self.assertIn("offset=100", list_urls[1])
|
||||||
|
self.assertIn("offset=200", list_urls[2])
|
||||||
|
|
||||||
|
def test_recent_helpdesk_issues_skips_duplicate_issue_ids_across_pages(self):
|
||||||
|
source = DuplicatePagedRedmineSource()
|
||||||
|
|
||||||
|
issues = list(source.recent_helpdesk_issues(limit=3))
|
||||||
|
|
||||||
|
self.assertEqual([1, 2, 3], [issue["id"] for issue in issues])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from semantic_index.__main__ import main
|
||||||
|
from semantic_index.models import IndexDocument
|
||||||
|
from semantic_index.refresh import FileRefreshState, RedmineRefreshService
|
||||||
|
|
||||||
|
|
||||||
|
def issue(updated_on="2026-04-25T12:00:00Z"):
|
||||||
|
return {
|
||||||
|
"id": 39779,
|
||||||
|
"subject": "Goods return",
|
||||||
|
"description": "Please return our goods.",
|
||||||
|
"updated_on": updated_on,
|
||||||
|
"project": {"id": 1, "identifier": "customer-service", "name": "Customer Service"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeRedmineSource:
|
||||||
|
project_identifier = None
|
||||||
|
|
||||||
|
def __init__(self, issues=None):
|
||||||
|
self.issues = issues or [issue()]
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def recent_helpdesk_issues(self, limit):
|
||||||
|
self.calls.append((self.project_identifier, limit))
|
||||||
|
return self.issues[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
class SummaryDetailRedmineSource(FakeRedmineSource):
|
||||||
|
def __init__(self, summaries, details):
|
||||||
|
super().__init__([])
|
||||||
|
self.summaries = summaries
|
||||||
|
self.details = details
|
||||||
|
self.summary_calls = []
|
||||||
|
self.detail_calls = []
|
||||||
|
|
||||||
|
def recent_issue_summaries(self, limit):
|
||||||
|
self.summary_calls.append((self.project_identifier, limit))
|
||||||
|
return self.summaries[:limit]
|
||||||
|
|
||||||
|
def issue_detail(self, issue_id):
|
||||||
|
self.detail_calls.append(issue_id)
|
||||||
|
return self.details[issue_id]
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingEmbedder:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def embed_documents(self, docs):
|
||||||
|
self.calls.append(list(docs))
|
||||||
|
return [[0.1, 0.2, 0.3] for _ in docs]
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshStore:
|
||||||
|
def __init__(self, existing=None):
|
||||||
|
self.existing = existing or {}
|
||||||
|
self.upserts = []
|
||||||
|
self.deleted_ids = []
|
||||||
|
|
||||||
|
def list_documents(self, limit=10, source=None, project_identifier=None, doc_type=None, issue_id=None):
|
||||||
|
return list(self.existing.values())[:limit]
|
||||||
|
|
||||||
|
def upsert(self, docs, vectors):
|
||||||
|
self.upserts.append((list(docs), list(vectors)))
|
||||||
|
|
||||||
|
def delete_documents(self, document_ids):
|
||||||
|
self.deleted_ids.extend(document_ids)
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineRefreshServiceTest(unittest.TestCase):
|
||||||
|
def test_refresh_skips_embeddings_when_source_hash_matches_existing_document(self):
|
||||||
|
source = FakeRedmineSource()
|
||||||
|
embedder = RecordingEmbedder()
|
||||||
|
service = RedmineRefreshService(source=source, embedder=embedder, store=RefreshStore())
|
||||||
|
candidate = service.mapper.issue_to_documents(issue())[0]
|
||||||
|
service.store.existing[candidate.id] = {
|
||||||
|
"id": candidate.id,
|
||||||
|
"text": candidate.text,
|
||||||
|
"payload": dict(candidate.payload),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = service.refresh_redmine_project_limits({"customer-service": 1})
|
||||||
|
|
||||||
|
self.assertEqual(1, result["unchanged_documents"])
|
||||||
|
self.assertEqual(0, result["embedded_documents"])
|
||||||
|
self.assertEqual([], embedder.calls)
|
||||||
|
self.assertEqual([], service.store.upserts)
|
||||||
|
|
||||||
|
def test_refresh_embeds_only_changed_and_new_documents(self):
|
||||||
|
source = FakeRedmineSource()
|
||||||
|
embedder = RecordingEmbedder()
|
||||||
|
service = RedmineRefreshService(source=source, embedder=embedder, store=RefreshStore())
|
||||||
|
candidate = service.mapper.issue_to_documents(issue())[0]
|
||||||
|
service.store.existing[candidate.id] = {
|
||||||
|
"id": candidate.id,
|
||||||
|
"text": "Old text",
|
||||||
|
"payload": {**candidate.payload, "source_hash": "old-hash"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = service.refresh_redmine_project_limits({"customer-service": 1})
|
||||||
|
|
||||||
|
self.assertEqual(1, result["changed_documents"])
|
||||||
|
self.assertEqual(1, result["embedded_documents"])
|
||||||
|
self.assertEqual([[candidate]], embedder.calls)
|
||||||
|
self.assertEqual([candidate.id], [doc.id for doc in service.store.upserts[0][0]])
|
||||||
|
|
||||||
|
def test_refresh_deletes_stale_issue_documents_without_embedding(self):
|
||||||
|
source = FakeRedmineSource()
|
||||||
|
embedder = RecordingEmbedder()
|
||||||
|
service = RedmineRefreshService(source=source, embedder=embedder, store=RefreshStore())
|
||||||
|
candidate = service.mapper.issue_to_documents(issue())[0]
|
||||||
|
service.store.existing[candidate.id] = {"id": candidate.id, "text": candidate.text, "payload": dict(candidate.payload)}
|
||||||
|
service.store.existing["redmine:issue:39779:journal:1:chunk:0"] = {
|
||||||
|
"id": "redmine:issue:39779:journal:1:chunk:0",
|
||||||
|
"text": "Deleted note",
|
||||||
|
"payload": {"source_hash": "gone", "issue_id": 39779},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = service.refresh_redmine_project_limits({"customer-service": 1})
|
||||||
|
|
||||||
|
self.assertEqual(1, result["stale_documents"])
|
||||||
|
self.assertEqual(["redmine:issue:39779:journal:1:chunk:0"], service.store.deleted_ids)
|
||||||
|
self.assertEqual([], embedder.calls)
|
||||||
|
|
||||||
|
def test_dry_run_reports_planned_embeddings_without_embedding_or_mutating(self):
|
||||||
|
source = FakeRedmineSource()
|
||||||
|
embedder = RecordingEmbedder()
|
||||||
|
service = RedmineRefreshService(source=source, embedder=embedder, store=RefreshStore())
|
||||||
|
|
||||||
|
result = service.refresh_redmine_project_limits({"customer-service": 1}, dry_run=True)
|
||||||
|
|
||||||
|
self.assertEqual(1, result["new_documents"])
|
||||||
|
self.assertEqual(1, result["would_embed_documents"])
|
||||||
|
self.assertEqual(0, result["embedded_documents"])
|
||||||
|
self.assertEqual([], embedder.calls)
|
||||||
|
self.assertEqual([], service.store.upserts)
|
||||||
|
self.assertEqual([], service.store.deleted_ids)
|
||||||
|
|
||||||
|
def test_force_rebuild_embeds_unchanged_documents(self):
|
||||||
|
source = FakeRedmineSource()
|
||||||
|
embedder = RecordingEmbedder()
|
||||||
|
service = RedmineRefreshService(source=source, embedder=embedder, store=RefreshStore())
|
||||||
|
candidate = service.mapper.issue_to_documents(issue())[0]
|
||||||
|
service.store.existing[candidate.id] = {"id": candidate.id, "text": candidate.text, "payload": dict(candidate.payload)}
|
||||||
|
|
||||||
|
result = service.refresh_redmine_project_limits({"customer-service": 1}, force_rebuild=True)
|
||||||
|
|
||||||
|
self.assertEqual(1, result["force_rebuilt_documents"])
|
||||||
|
self.assertEqual(1, result["embedded_documents"])
|
||||||
|
self.assertEqual([[candidate]], embedder.calls)
|
||||||
|
|
||||||
|
def test_force_rebuild_ignores_refresh_state_window_for_fetched_candidates(self):
|
||||||
|
source = FakeRedmineSource([issue(updated_on="2026-04-25T10:00:00Z")])
|
||||||
|
embedder = RecordingEmbedder()
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
state = FileRefreshState(Path(tmp) / "refresh.json")
|
||||||
|
state.mark_success("customer-service", "2026-04-25T12:00:00Z")
|
||||||
|
service = RedmineRefreshService(source=source, embedder=embedder, store=RefreshStore(), state=state)
|
||||||
|
|
||||||
|
result = service.refresh_redmine_project_limits({"customer-service": 1}, force_rebuild=True, overlap_minutes=15)
|
||||||
|
|
||||||
|
self.assertEqual(0, result["skipped_issues"])
|
||||||
|
self.assertEqual(1, result["embedded_documents"])
|
||||||
|
|
||||||
|
def test_file_refresh_state_updates_only_when_called(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
state = FileRefreshState(Path(tmp) / "refresh.json")
|
||||||
|
self.assertEqual({}, state.load())
|
||||||
|
|
||||||
|
state.mark_success("customer-service", "2026-04-25T12:00:00Z")
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
{"projects": {"customer-service": {"last_successful_refresh_at": "2026-04-25T12:00:00Z"}}},
|
||||||
|
json.loads((Path(tmp) / "refresh.json").read_text(encoding="utf-8")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_refresh_state_skips_issues_older_than_overlap_window(self):
|
||||||
|
source = FakeRedmineSource([issue(updated_on="2026-04-25T10:00:00Z")])
|
||||||
|
embedder = RecordingEmbedder()
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
state = FileRefreshState(Path(tmp) / "refresh.json")
|
||||||
|
state.mark_success("customer-service", "2026-04-25T12:00:00Z")
|
||||||
|
service = RedmineRefreshService(source=source, embedder=embedder, store=RefreshStore(), state=state)
|
||||||
|
|
||||||
|
result = service.refresh_redmine_project_limits({"customer-service": 1}, dry_run=True, overlap_minutes=15)
|
||||||
|
|
||||||
|
self.assertEqual(1, result["issues"])
|
||||||
|
self.assertEqual(1, result["skipped_issues"])
|
||||||
|
self.assertEqual(0, result["documents"])
|
||||||
|
self.assertEqual([], embedder.calls)
|
||||||
|
|
||||||
|
def test_refresh_skips_old_summaries_without_fetching_issue_detail(self):
|
||||||
|
old_summary = {"id": 39779, "updated_on": "2026-04-25T10:00:00Z"}
|
||||||
|
new_summary = {"id": 39780, "updated_on": "2026-04-25T11:50:00Z"}
|
||||||
|
source = SummaryDetailRedmineSource(
|
||||||
|
summaries=[old_summary, new_summary],
|
||||||
|
details={39780: {**issue("2026-04-25T11:50:00Z"), "id": 39780}},
|
||||||
|
)
|
||||||
|
embedder = RecordingEmbedder()
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
state = FileRefreshState(Path(tmp) / "refresh.json")
|
||||||
|
state.mark_success("customer-service", "2026-04-25T12:00:00Z")
|
||||||
|
service = RedmineRefreshService(source=source, embedder=embedder, store=RefreshStore(), state=state)
|
||||||
|
|
||||||
|
result = service.refresh_redmine_project_limits({"customer-service": 2}, dry_run=True, overlap_minutes=15)
|
||||||
|
|
||||||
|
self.assertEqual(2, result["scanned_issues"])
|
||||||
|
self.assertEqual(1, result["skipped_issues"])
|
||||||
|
self.assertEqual(1, result["detail_fetched_issues"])
|
||||||
|
self.assertEqual([39780], source.detail_calls)
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshCliTest(unittest.TestCase):
|
||||||
|
def test_refresh_redmine_projects_cli_parses_project_limits_and_dry_run(self):
|
||||||
|
class FakeRefresh:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def refresh_redmine_project_limits(self, project_limits, dry_run=False, force_rebuild=False, overlap_minutes=15):
|
||||||
|
self.calls.append((project_limits, dry_run, force_rebuild, overlap_minutes))
|
||||||
|
return {"source": "redmine", "projects": len(project_limits), "issues": sum(project_limits.values())}
|
||||||
|
|
||||||
|
refresh = FakeRefresh()
|
||||||
|
services = {"refresh": refresh}
|
||||||
|
out = io.StringIO()
|
||||||
|
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main(
|
||||||
|
[
|
||||||
|
"--refresh-redmine-projects",
|
||||||
|
"--project-limits",
|
||||||
|
"customer-service=5,hiring=2",
|
||||||
|
"--dry-run",
|
||||||
|
"--overlap-minutes",
|
||||||
|
"30",
|
||||||
|
],
|
||||||
|
service_builder=lambda: services,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(({"customer-service": 5, "hiring": 2}, True, False, 30), refresh.calls[0])
|
||||||
|
self.assertIn("'projects': 2", out.getvalue())
|
||||||
|
|
||||||
|
def test_refresh_redmine_projects_cli_can_override_state_path(self):
|
||||||
|
class FakeRefresh:
|
||||||
|
def __init__(self):
|
||||||
|
self.state = None
|
||||||
|
|
||||||
|
def refresh_redmine_project_limits(self, project_limits, dry_run=False, force_rebuild=False, overlap_minutes=15):
|
||||||
|
return {"state_path": str(self.state.path)}
|
||||||
|
|
||||||
|
refresh = FakeRefresh()
|
||||||
|
out = io.StringIO()
|
||||||
|
|
||||||
|
with redirect_stdout(out):
|
||||||
|
main(
|
||||||
|
[
|
||||||
|
"--refresh-redmine-projects",
|
||||||
|
"--project-limits",
|
||||||
|
"customer-service=1",
|
||||||
|
"--state-path",
|
||||||
|
"/tmp/semantic-refresh-state.json",
|
||||||
|
],
|
||||||
|
service_builder=lambda: {"refresh": refresh},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("/tmp/semantic-refresh-state.json", out.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from semantic_index.models import IndexDocument, SearchQuery, SearchResult
|
||||||
|
from semantic_index.qdrant_store import build_filter, point_id_for_document
|
||||||
|
from semantic_index.search import HybridSearchService, keyword_boost
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEmbedder:
|
||||||
|
def embed_query(self, text):
|
||||||
|
return [0.1, 0.2, 0.3]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeStore:
|
||||||
|
def __init__(self):
|
||||||
|
self.query = None
|
||||||
|
|
||||||
|
def search(self, vector, query, limit):
|
||||||
|
self.query = query
|
||||||
|
return [
|
||||||
|
SearchResult(
|
||||||
|
id="weak",
|
||||||
|
score=0.7,
|
||||||
|
text="general support text",
|
||||||
|
payload={"redmine_url": "http://redmine/issues/1"},
|
||||||
|
),
|
||||||
|
SearchResult(
|
||||||
|
id="strong",
|
||||||
|
score=0.6,
|
||||||
|
text="Customer ada@example.com asked about ORD-12345",
|
||||||
|
payload={"redmine_url": "http://redmine/issues/2"},
|
||||||
|
),
|
||||||
|
][:limit]
|
||||||
|
|
||||||
|
|
||||||
|
class SearchTest(unittest.TestCase):
|
||||||
|
def test_qdrant_point_id_is_deterministic_uuid_for_stable_document_id(self):
|
||||||
|
first = point_id_for_document("redmine:issue:42:journal:5:chunk:0")
|
||||||
|
second = point_id_for_document("redmine:issue:42:journal:5:chunk:0")
|
||||||
|
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
self.assertRegex(first, r"^[0-9a-f-]{36}$")
|
||||||
|
|
||||||
|
def test_filter_maps_supported_metadata(self):
|
||||||
|
query = SearchQuery(
|
||||||
|
text="printer",
|
||||||
|
source="redmine",
|
||||||
|
project_identifier="fud-helpdesk",
|
||||||
|
doc_type="message",
|
||||||
|
issue_id=42,
|
||||||
|
contact_email="ada@example.com",
|
||||||
|
date_from="2026-04-01T00:00:00Z",
|
||||||
|
date_to="2026-04-30T23:59:59Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
qfilter = build_filter(query)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
[
|
||||||
|
{"key": "source", "match": {"value": "redmine"}},
|
||||||
|
{"key": "project_identifier", "match": {"value": "fud-helpdesk"}},
|
||||||
|
{"key": "doc_type", "match": {"value": "message"}},
|
||||||
|
{"key": "issue_id", "match": {"value": 42}},
|
||||||
|
{"key": "contact_email", "match": {"value": "ada@example.com"}},
|
||||||
|
{"key": "created_on", "range": {"gte": "2026-04-01T00:00:00Z", "lte": "2026-04-30T23:59:59Z"}},
|
||||||
|
],
|
||||||
|
qfilter["must"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_keyword_boost_prioritizes_exact_email_and_order_matches(self):
|
||||||
|
weak = SearchResult(id="weak", score=0.7, text="general support text", payload={})
|
||||||
|
strong = SearchResult(id="strong", score=0.6, text="Customer ada@example.com asked about ORD-12345", payload={})
|
||||||
|
|
||||||
|
self.assertGreater(
|
||||||
|
keyword_boost('ada@example.com "ORD-12345"', strong),
|
||||||
|
keyword_boost('ada@example.com "ORD-12345"', weak),
|
||||||
|
)
|
||||||
|
|
||||||
|
service = HybridSearchService(embedder=FakeEmbedder(), store=FakeStore())
|
||||||
|
results = service.search(SearchQuery(text='ada@example.com "ORD-12345"', limit=2))
|
||||||
|
self.assertEqual("strong", results[0].id)
|
||||||
|
self.assertEqual("http://redmine/issues/2", results[0].citation["url"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
REFRESH = ROOT / "semantic_index" / "refresh.sh"
|
||||||
|
|
||||||
|
|
||||||
|
class SemanticIndexShellWrapperTest(unittest.TestCase):
|
||||||
|
def test_refresh_wrapper_is_self_locating_when_called_from_another_directory(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
tmp_path = Path(tmp)
|
||||||
|
env = {
|
||||||
|
**os.environ,
|
||||||
|
"PYTHON": "/bin/echo",
|
||||||
|
"SEMANTIC_INDEX_PROJECT_LIMITS": "customer-service=5",
|
||||||
|
"SEMANTIC_INDEX_LOG_DIR": str(tmp_path / "logs"),
|
||||||
|
"SEMANTIC_INDEX_STATE_PATH": str(tmp_path / "state" / "refresh_state.json"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(REFRESH)],
|
||||||
|
cwd=tmp,
|
||||||
|
env=env,
|
||||||
|
text=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
self.assertIn("-m semantic_index --refresh-redmine-projects", result.stdout)
|
||||||
|
self.assertIn("--project-limits customer-service=5", result.stdout)
|
||||||
|
self.assertIn("log_file=", result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
MIGRATION = ROOT / "plugins" / "redmine_event_outbox" / "db" / "migrate" / "001_create_event_outbox_events.rb"
|
||||||
|
|
||||||
|
|
||||||
|
class EventOutboxMigrationTest(unittest.TestCase):
|
||||||
|
def test_create_table_migration_is_idempotent_for_imported_dev_clone(self):
|
||||||
|
source = MIGRATION.read_text()
|
||||||
|
|
||||||
|
self.assertIn("table_exists?(:event_outbox_events)", source)
|
||||||
|
self.assertIn("return if table_exists?(:event_outbox_events)", source)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from post_import_refresh import AutomationConfig, StepResult, build_steps, write_status
|
||||||
|
|
||||||
|
|
||||||
|
class PostImportRefreshPlanTest(unittest.TestCase):
|
||||||
|
def test_dry_run_is_the_default_and_never_enables_index_writes(self):
|
||||||
|
config = AutomationConfig()
|
||||||
|
steps = build_steps(config)
|
||||||
|
commands = "\n".join(command for step in steps for command in step.commands)
|
||||||
|
|
||||||
|
self.assertFalse(config.apply)
|
||||||
|
self.assertIn("validate semantic index dry-run", [step.name for step in steps])
|
||||||
|
self.assertIn("semantic_index/refresh.sh", commands)
|
||||||
|
self.assertNotIn("semantic_index/refresh.sh --apply", commands)
|
||||||
|
self.assertNotIn("--force-rebuild", commands)
|
||||||
|
self.assertNotIn("systemctl enable --now semantic-index-refresh.timer", commands)
|
||||||
|
|
||||||
|
def test_plugin_reapply_happens_before_migrations_and_helpdesk_reset(self):
|
||||||
|
names = [step.name for step in build_steps(AutomationConfig())]
|
||||||
|
|
||||||
|
self.assertLess(names.index("reapply tracked plugins"), names.index("run plugin migrations"))
|
||||||
|
self.assertLess(names.index("run plugin migrations"), names.index("reset Helpdesk mail settings"))
|
||||||
|
|
||||||
|
def test_expected_plugins_are_reapplied_to_remote_redmine_tree(self):
|
||||||
|
steps = build_steps(AutomationConfig())
|
||||||
|
plugin_step = next(step for step in steps if step.name == "reapply tracked plugins")
|
||||||
|
commands = "\n".join(plugin_step.commands)
|
||||||
|
|
||||||
|
self.assertIn("plugins/redmine_event_outbox", commands)
|
||||||
|
self.assertIn("plugins/redmine_contacts", commands)
|
||||||
|
self.assertIn("plugins/redmine_contacts_helpdesk", commands)
|
||||||
|
self.assertNotIn("plugins/redmine_event_outbox/", commands)
|
||||||
|
self.assertIn("reddev@192.168.50.170:/usr/share/redmine/plugins/", commands)
|
||||||
|
|
||||||
|
def test_apply_mode_runs_mutating_validation_sequence(self):
|
||||||
|
steps = build_steps(AutomationConfig(apply=True))
|
||||||
|
commands = "\n".join(command for step in steps for command in step.commands)
|
||||||
|
|
||||||
|
self.assertIn("bundle exec rake redmine:plugins:migrate", commands)
|
||||||
|
self.assertIn("./reset_helpdesk_mail_settings.py", commands)
|
||||||
|
self.assertIn("touch tmp/restart.txt", commands)
|
||||||
|
self.assertIn("./validate_test_instance.py", commands)
|
||||||
|
|
||||||
|
def test_remote_write_steps_use_sudo_by_default(self):
|
||||||
|
commands = "\n".join(command for step in build_steps(AutomationConfig()) for command in step.commands)
|
||||||
|
|
||||||
|
self.assertIn("--rsync-path 'sudo rsync'", commands)
|
||||||
|
self.assertIn("sudo mkdir -p", commands)
|
||||||
|
self.assertIn("sudo chmod -R g+rwX", commands)
|
||||||
|
|
||||||
|
def test_local_mode_emits_local_commands_without_ssh(self):
|
||||||
|
config = AutomationConfig(local=True)
|
||||||
|
commands = "\n".join(command for step in build_steps(config) for command in step.commands)
|
||||||
|
|
||||||
|
self.assertNotIn("ssh -i", commands)
|
||||||
|
self.assertNotIn("rsync-path", commands)
|
||||||
|
self.assertIn("reset_helpdesk_mail_settings.py --local", commands)
|
||||||
|
self.assertIn("validate_test_instance.py --local", commands)
|
||||||
|
self.assertNotIn("--composer-bin", commands)
|
||||||
|
self.assertIn("redmine_outbox_worker.py --local --status", commands)
|
||||||
|
self.assertIn("/opt/lanscratch/redmine-post-import/repo/plugins/redmine_event_outbox", commands)
|
||||||
|
self.assertIn("/usr/share/redmine/plugins/", commands)
|
||||||
|
self.assertIn("cd /usr/share/redmine && RAILS_ENV=production bundle exec rake redmine:plugins:migrate", commands)
|
||||||
|
|
||||||
|
def test_local_semantic_check_is_non_blocking_without_staged_venv(self):
|
||||||
|
config = AutomationConfig(local=True)
|
||||||
|
semantic_step = next(step for step in build_steps(config) if step.name == "validate semantic index dry-run")
|
||||||
|
command = semantic_step.commands[0]
|
||||||
|
|
||||||
|
self.assertIn("test -x /opt/lanscratch/redmine-post-import/repo/.venv/bin/python", command)
|
||||||
|
self.assertIn("semantic index runtime missing; skipping dry-run", command)
|
||||||
|
self.assertIn("else", command)
|
||||||
|
|
||||||
|
def test_status_paths_default_to_lanscratch(self):
|
||||||
|
config = AutomationConfig()
|
||||||
|
|
||||||
|
self.assertEqual(Path("/opt/lanscratch/redmine-post-import/status"), config.status_dir)
|
||||||
|
|
||||||
|
def test_write_status_updates_latest_and_success_only_on_success(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
config = AutomationConfig(status_dir=Path(tmp))
|
||||||
|
failed = write_status(
|
||||||
|
config,
|
||||||
|
run_id="20260428T010000Z",
|
||||||
|
status="failed",
|
||||||
|
results=[StepResult("preflight", "test -d missing", 1)],
|
||||||
|
failed_step="preflight",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue((Path(tmp) / "latest.json").exists())
|
||||||
|
self.assertTrue((Path(tmp) / "runs" / "20260428T010000Z.json").exists())
|
||||||
|
self.assertFalse((Path(tmp) / "latest-success.json").exists())
|
||||||
|
self.assertEqual("failed", failed["status"])
|
||||||
|
|
||||||
|
successful = write_status(
|
||||||
|
config,
|
||||||
|
run_id="20260428T010100Z",
|
||||||
|
status="success",
|
||||||
|
results=[StepResult("preflight", "test -d plugins", 0)],
|
||||||
|
)
|
||||||
|
|
||||||
|
latest_success = json.loads((Path(tmp) / "latest-success.json").read_text())
|
||||||
|
self.assertEqual(successful["run_id"], latest_success["run_id"])
|
||||||
|
self.assertEqual("success", latest_success["status"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
POST_IMPORT_SCRIPTS = [
|
||||||
|
ROOT / "post_import_refresh.py",
|
||||||
|
ROOT / "stage_post_import_payload.py",
|
||||||
|
ROOT / "reset_helpdesk_mail_settings.py",
|
||||||
|
ROOT / "validate_test_instance.py",
|
||||||
|
ROOT / "redmine_outbox_worker.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Python36CompatTest(unittest.TestCase):
|
||||||
|
def test_post_import_scripts_do_not_use_subprocess_text_keyword(self):
|
||||||
|
for path in POST_IMPORT_SCRIPTS:
|
||||||
|
with self.subTest(path=path.name):
|
||||||
|
self.assertNotIn("text=True", path.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SKILL = ROOT / "skills" / "redmine-communicator"
|
||||||
|
SETUP = SKILL / "scripts" / "setup_redmcp.py"
|
||||||
|
|
||||||
|
|
||||||
|
class RedmineCommunicatorSkillTest(unittest.TestCase):
|
||||||
|
def test_skill_files_exist_and_reference_redmcp_safety_rules(self):
|
||||||
|
skill_md = (SKILL / "SKILL.md").read_text()
|
||||||
|
reference = (SKILL / "references" / "redmcp-tools.md").read_text()
|
||||||
|
|
||||||
|
self.assertIn("redmine-communicator", skill_md)
|
||||||
|
self.assertIn("redMCP", skill_md)
|
||||||
|
self.assertIn("send_helpdesk_email=true", skill_md)
|
||||||
|
self.assertIn("redmine_send_helpdesk_response", reference)
|
||||||
|
self.assertIn("customer-visible", reference)
|
||||||
|
|
||||||
|
def test_setup_script_dry_run_prints_stdio_config(self):
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(SETUP),
|
||||||
|
"--redmine-url",
|
||||||
|
"http://redmine.example.test",
|
||||||
|
"--redmine-api-key",
|
||||||
|
"secret-key",
|
||||||
|
"--transport",
|
||||||
|
"stdio",
|
||||||
|
],
|
||||||
|
cwd=ROOT,
|
||||||
|
universal_newlines=True,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
self.assertIn("mode=dry-run", result.stdout)
|
||||||
|
self.assertIn("redmcp-server.php", result.stdout)
|
||||||
|
self.assertNotIn("secret-key", result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from stage_post_import_payload import build_rsync_command
|
||||||
|
|
||||||
|
|
||||||
|
class StagePostImportPayloadTest(unittest.TestCase):
|
||||||
|
def test_stage_command_targets_lanscratch_and_excludes_runtime_files(self):
|
||||||
|
command = build_rsync_command(
|
||||||
|
repo_root=Path("/repo"),
|
||||||
|
target=Path("/opt/lanscratch/redmine-post-import/repo"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("/opt/lanscratch/redmine-post-import/repo/", command)
|
||||||
|
self.assertIn("/repo/plugins", command)
|
||||||
|
self.assertIn("/repo/post_import_refresh.py", command)
|
||||||
|
self.assertIn("/repo/stage_post_import_payload.py", command)
|
||||||
|
self.assertIn("--exclude .env", command)
|
||||||
|
self.assertIn("--exclude .venv", command)
|
||||||
|
self.assertIn("--exclude .cache", command)
|
||||||
|
self.assertIn("--exclude __pycache__/", command)
|
||||||
|
self.assertIn("--exclude '*.tar.gz'", command)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
import validate_test_instance
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateTestInstanceTest(unittest.TestCase):
|
||||||
|
def test_missing_controlled_projects_are_warn_for_daily_clone(self):
|
||||||
|
results = validate_test_instance.controlled_project_check([])
|
||||||
|
|
||||||
|
self.assertEqual("WARN", results.status)
|
||||||
|
self.assertIn("optional", results.detail)
|
||||||
|
|
||||||
|
def test_composer_validation_is_skipped_when_disabled(self):
|
||||||
|
result = validate_test_instance.check_composer(None)
|
||||||
|
|
||||||
|
self.assertEqual("WARN", result.status)
|
||||||
|
self.assertIn("skipped", result.detail)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+78
-50
@@ -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("'", "'\"'\"'") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user