Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d105a70ad | |||
| ca56b583c5 | |||
|
|
8c35e91aef | ||
|
|
1055841b6f | ||
| f38ca7e2fc | |||
| a9956a16a4 | |||
|
|
bc50a78db7 | ||
|
|
e6d1965342 | ||
|
|
708e1e9c18 | ||
|
|
5b45c6c418 | ||
|
|
096068ed2b | ||
|
|
9248811076 | ||
|
|
007d0ee9d5 | ||
|
|
9e1a9b1d34 | ||
| cdb1dd1dad | |||
| 6dbe40ef03 | |||
|
|
75fc3ca67c | ||
| fdae6d6e6d | |||
|
|
d78181e8c0 | ||
|
|
b4ff52108a | ||
| 29a68c07e9 | |||
|
|
40a2c25428 | ||
|
|
62e3570a15 | ||
| 41e5753de6 | |||
|
|
25201eaac1 | ||
| 618eb6b43d | |||
|
|
5084dca5e3 | ||
|
|
6cbdcaed21 | ||
|
|
8298506435 | ||
| 412c5e70f0 | |||
| 05f87a7bff | |||
|
|
8e1d43da43 | ||
|
|
2d7aac8413 | ||
|
|
84c69fbea8 | ||
| 9bc570774a | |||
| f7011c8837 | |||
|
|
f74238a65a | ||
|
|
2da529fb75 | ||
| 2f6d5c1865 | |||
|
|
280a9f042e | ||
| 41bc5f38ff | |||
|
|
6d2b69ffb0 | ||
| eae1c6e8b7 | |||
|
|
27a46a7542 |
@ -65,6 +65,138 @@ jobs:
|
||||
|
||||
echo "Tag $NEXT pushed successfully"
|
||||
|
||||
changelog:
|
||||
needs: autotag
|
||||
runs-on: linux-amd64
|
||||
container:
|
||||
image: alpine:latest
|
||||
steps:
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
set -eu
|
||||
apk add --no-cache git curl jq
|
||||
|
||||
- name: Checkout (full history + all tags)
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
git init
|
||||
git remote add origin \
|
||||
"http://oauth2:${RELEASE_TOKEN}@172.0.0.29:3000/${GITHUB_REPOSITORY}.git"
|
||||
git fetch --tags --depth=2147483647 origin
|
||||
git checkout FETCH_HEAD
|
||||
git config user.name "gitea-actions[bot]"
|
||||
git config user.email "gitea-actions@local"
|
||||
|
||||
- name: Install git-cliff
|
||||
run: |
|
||||
set -eu
|
||||
CLIFF_VER="2.7.0"
|
||||
curl -fsSL \
|
||||
"https://github.com/orhun/git-cliff/releases/download/v${CLIFF_VER}/git-cliff-${CLIFF_VER}-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar -xz --strip-components=1 -C /usr/local/bin \
|
||||
"git-cliff-${CLIFF_VER}/git-cliff"
|
||||
|
||||
- name: Generate changelog
|
||||
run: |
|
||||
set -eu
|
||||
git-cliff --config cliff.toml --output CHANGELOG.md
|
||||
git-cliff --config cliff.toml --latest --strip all > /tmp/release_body.md
|
||||
echo "=== Release body preview ==="
|
||||
cat /tmp/release_body.md
|
||||
|
||||
- name: Update Gitea release body
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
API="http://172.0.0.29:3000/api/v1/repos/$GITHUB_REPOSITORY"
|
||||
TAG=$(git describe --tags --abbrev=0)
|
||||
# Create release if it doesn't exist yet (build jobs may still be running)
|
||||
curl -sf -X POST "$API/releases" \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\":\"$TAG\",\"name\":\"TFTSR $TAG\",\"body\":\"Release $TAG\",\"draft\":false}" || true
|
||||
RELEASE_ID=$(curl -sf "$API/releases/tags/$TAG" \
|
||||
-H "Authorization: token $RELEASE_TOKEN" | jq -r '.id')
|
||||
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
|
||||
echo "ERROR: Failed to get release ID for $TAG"
|
||||
exit 1
|
||||
fi
|
||||
curl -sf -X PATCH "$API/releases/$RELEASE_ID" \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary "{\"body\":$(jq -Rs . < /tmp/release_body.md)}"
|
||||
echo "✓ Release body updated"
|
||||
|
||||
- name: Commit CHANGELOG.md to master
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
API="http://172.0.0.29:3000/api/v1/repos/$GITHUB_REPOSITORY"
|
||||
TAG=$(git describe --tags --abbrev=0)
|
||||
# Validate tag format to prevent shell injection in commit message / JSON
|
||||
if ! echo "$TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "ERROR: Unexpected tag format: $TAG"
|
||||
exit 1
|
||||
fi
|
||||
# Fetch current blob SHA from master; empty if file doesn't exist yet
|
||||
CURRENT_SHA=$(curl -sf \
|
||||
-H "Accept: application/json" \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
"$API/contents/CHANGELOG.md?ref=master" 2>/dev/null \
|
||||
| jq -r '.sha // empty' 2>/dev/null || true)
|
||||
# Base64-encode content (no line wrapping)
|
||||
CONTENT=$(base64 -w 0 CHANGELOG.md)
|
||||
# Build JSON payload — omit "sha" when file doesn't exist yet (new repo)
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg msg "chore: update CHANGELOG.md for ${TAG} [skip ci]" \
|
||||
--arg body "$CONTENT" \
|
||||
--arg sha "$CURRENT_SHA" \
|
||||
'if $sha == ""
|
||||
then {message: $msg, content: $body, branch: "master"}
|
||||
else {message: $msg, content: $body, sha: $sha, branch: "master"}
|
||||
end')
|
||||
# PUT atomically updates (or creates) the file on master — no fast-forward needed
|
||||
RESP_FILE=$(mktemp)
|
||||
HTTP_CODE=$(curl -s -o "$RESP_FILE" -w "%{http_code}" -X PUT \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$API/contents/CHANGELOG.md")
|
||||
if [ "$HTTP_CODE" -lt 200 ] || [ "$HTTP_CODE" -ge 300 ]; then
|
||||
echo "ERROR: Failed to update CHANGELOG.md (HTTP $HTTP_CODE)"
|
||||
cat "$RESP_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ CHANGELOG.md committed to master"
|
||||
|
||||
- name: Upload CHANGELOG.md as release asset
|
||||
env:
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
API="http://172.0.0.29:3000/api/v1/repos/$GITHUB_REPOSITORY"
|
||||
TAG=$(git describe --tags --abbrev=0)
|
||||
RELEASE_ID=$(curl -sf "$API/releases/tags/$TAG" \
|
||||
-H "Authorization: token $RELEASE_TOKEN" | jq -r '.id')
|
||||
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
|
||||
echo "ERROR: Failed to get release ID for $TAG"
|
||||
exit 1
|
||||
fi
|
||||
EXISTING=$(curl -sf "$API/releases/$RELEASE_ID" \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
| jq -r '.assets[]? | select(.name=="CHANGELOG.md") | .id')
|
||||
[ -n "$EXISTING" ] && curl -sf -X DELETE \
|
||||
"$API/releases/$RELEASE_ID/assets/$EXISTING" \
|
||||
-H "Authorization: token $RELEASE_TOKEN"
|
||||
curl -sf -X POST "$API/releases/$RELEASE_ID/assets" \
|
||||
-H "Authorization: token $RELEASE_TOKEN" \
|
||||
-F "attachment=@CHANGELOG.md;filename=CHANGELOG.md"
|
||||
echo "✓ CHANGELOG.md uploaded"
|
||||
|
||||
wiki-sync:
|
||||
runs-on: linux-amd64
|
||||
container:
|
||||
@ -158,6 +290,8 @@ jobs:
|
||||
restore-keys: |
|
||||
${{ runner.os }}-npm-
|
||||
- name: Build
|
||||
env:
|
||||
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||
run: |
|
||||
npm ci --legacy-peer-deps
|
||||
CI=true npx tauri build --target x86_64-unknown-linux-gnu
|
||||
@ -188,7 +322,7 @@ jobs:
|
||||
fi
|
||||
echo "Release ID: $RELEASE_ID"
|
||||
ARTIFACTS=$(find src-tauri/target/x86_64-unknown-linux-gnu/release/bundle -type f \
|
||||
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \))
|
||||
\( -name "*.deb" -o -name "*.rpm" \))
|
||||
if [ -z "$ARTIFACTS" ]; then
|
||||
echo "ERROR: No Linux amd64 artifacts were found to upload."
|
||||
exit 1
|
||||
|
||||
@ -37,6 +37,11 @@ jobs:
|
||||
key: ${{ runner.os }}-cargo-linux-amd64-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-linux-amd64-
|
||||
- name: Install dependencies
|
||||
run: npm install --legacy-peer-deps
|
||||
- name: Update version from Git
|
||||
run: node scripts/update-version.mjs
|
||||
- run: cargo generate-lockfile --manifest-path src-tauri/Cargo.toml
|
||||
- run: cargo fmt --manifest-path src-tauri/Cargo.toml --check
|
||||
|
||||
rust-clippy:
|
||||
@ -72,7 +77,7 @@ jobs:
|
||||
key: ${{ runner.os }}-cargo-linux-amd64-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-linux-amd64-
|
||||
- run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml -- -D warnings
|
||||
- run: cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings
|
||||
|
||||
rust-tests:
|
||||
runs-on: ubuntu-latest
|
||||
@ -107,7 +112,7 @@ jobs:
|
||||
key: ${{ runner.os }}-cargo-linux-amd64-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-linux-amd64-
|
||||
- run: cargo test --locked --manifest-path src-tauri/Cargo.toml -- --test-threads=1
|
||||
- run: cargo test --manifest-path src-tauri/Cargo.toml -- --test-threads=1
|
||||
|
||||
frontend-typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
454
CHANGELOG.md
Normal file
454
CHANGELOG.md
Normal file
@ -0,0 +1,454 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to TFTSR are documented here.
|
||||
Commit types shown: feat, fix, perf, docs, refactor.
|
||||
CI, chore, and build changes are excluded.
|
||||
|
||||
## [0.2.65] — 2026-04-15
|
||||
|
||||
### Bug Fixes
|
||||
- Add --locked to cargo commands and improve version update script
|
||||
- Remove invalid --locked flag from cargo commands and fix format string
|
||||
- **integrations**: Security and correctness improvements
|
||||
- Correct WIQL syntax and escape_wiql implementation
|
||||
|
||||
### Features
|
||||
- Implement dynamic versioning from Git tags
|
||||
- **integrations**: Implement query expansion for semantic search
|
||||
|
||||
### Security
|
||||
- Fix query expansion issues from PR review
|
||||
- Address all issues from automated PR review
|
||||
|
||||
## [0.2.63] — 2026-04-13
|
||||
|
||||
### Bug Fixes
|
||||
- Add Windows nsis target and update CHANGELOG to v0.2.61
|
||||
|
||||
## [0.2.61] — 2026-04-13
|
||||
|
||||
### Bug Fixes
|
||||
- Remove AppImage from upload artifact patterns
|
||||
|
||||
## [0.2.59] — 2026-04-13
|
||||
|
||||
### Bug Fixes
|
||||
- Remove AppImage bundling to fix linux-amd64 build
|
||||
|
||||
## [0.2.57] — 2026-04-13
|
||||
|
||||
### Bug Fixes
|
||||
- Add fuse dependency for AppImage support
|
||||
|
||||
### Refactoring
|
||||
- Remove custom linuxdeploy install per CI CI uses tauri-downloaded version
|
||||
- Revert to original Dockerfile without manual linuxdeploy installation
|
||||
|
||||
## [0.2.56] — 2026-04-13
|
||||
|
||||
### Bug Fixes
|
||||
- Add missing ai_providers columns and fix linux-amd64 build
|
||||
- Address AI review findings
|
||||
- Address critical AI review issues
|
||||
|
||||
## [0.2.55] — 2026-04-13
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Use Gitea file API to push CHANGELOG.md — eliminates non-fast-forward rejection
|
||||
- **ci**: Harden CHANGELOG.md API push step per review
|
||||
|
||||
## [0.2.54] — 2026-04-13
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Correct git-cliff archive path in tar extraction
|
||||
|
||||
## [0.2.53] — 2026-04-13
|
||||
|
||||
### Features
|
||||
- **ci**: Add automated changelog generation via git-cliff
|
||||
|
||||
## [0.2.52] — 2026-04-13
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Add APPIMAGE_EXTRACT_AND_RUN to build-linux-amd64
|
||||
|
||||
## [0.2.51] — 2026-04-13
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Address AI review — rustup idempotency and cargo --locked
|
||||
- **ci**: Replace docker:24-cli with alpine + docker-cli in build-images
|
||||
- **docker**: Add ca-certificates to arm64 base image step 1
|
||||
- **ci**: Resolve test.yml failures — Cargo.lock, updated test assertions
|
||||
- **ci**: Address second AI review — || true, ca-certs, cache@v4, key suffixes
|
||||
|
||||
### Documentation
|
||||
- **docker**: Expand rebuild trigger comments to include OpenSSL and Tauri CLI
|
||||
|
||||
### Performance
|
||||
- **ci**: Use pre-baked images and add cargo/npm caching
|
||||
|
||||
## [0.2.50] — 2026-04-12
|
||||
|
||||
### Bug Fixes
|
||||
- Rename GITEA_TOKEN to TF_TOKEN to comply with naming restrictions
|
||||
- Remove actions/checkout to avoid Node.js dependency
|
||||
- Use ubuntu container with git installed
|
||||
- Use actions/checkout with token auth and self-hosted runner
|
||||
- Use IP addresses for internal services
|
||||
- Simplified workflow syntax
|
||||
- Add debugging output for Ollamaresponse
|
||||
- Correct Ollama URL, API endpoint, and JSON construction in pr-review workflow
|
||||
- Add diagnostics to identify empty Ollama response root cause
|
||||
- Use bash shell and remove bash-only substring expansion in pr-review
|
||||
- Restore migration 014, bump version to 0.2.50, harden pr-review workflow
|
||||
- Harden pr-review workflow and sync versions to 0.2.50
|
||||
- Configure container DNS to resolve ollama-ui.tftsr.com
|
||||
- Harden pr-review workflow — URLs, DNS, correctness and reliability
|
||||
- Resolve AI review false positives and address high/medium issues
|
||||
- Replace github.server_url with hardcoded gogs.tftsr.com for container access
|
||||
- Revert to two-dot diff — three-dot requires merge base unavailable in shallow clone
|
||||
- Harden pr-review workflow — secret redaction, log safety, auth header
|
||||
|
||||
### Features
|
||||
- Add automated PR review workflow with Ollama AI
|
||||
|
||||
## [0.2.49] — 2026-04-10
|
||||
|
||||
### Bug Fixes
|
||||
- Add missing ai_providers migration (014)
|
||||
|
||||
## [0.2.48] — 2026-04-10
|
||||
|
||||
### Bug Fixes
|
||||
- Lint fixes and formatting cleanup
|
||||
|
||||
### Features
|
||||
- Support GenAI datastore file uploads and fix paste image upload
|
||||
|
||||
## [0.2.47] — 2026-04-09
|
||||
|
||||
### Bug Fixes
|
||||
- Use 'provider' argument name to match Rust command signature
|
||||
|
||||
## [0.2.46] — 2026-04-09
|
||||
|
||||
### Bug Fixes
|
||||
- Add @types/testing-library__react for TypeScript compilation
|
||||
|
||||
### Update
|
||||
- Node_modules from npm install
|
||||
|
||||
## [0.2.45] — 2026-04-09
|
||||
|
||||
### Bug Fixes
|
||||
- Force single test thread for Rust tests to eliminate race conditions
|
||||
|
||||
## [0.2.43] — 2026-04-09
|
||||
|
||||
### Bug Fixes
|
||||
- Fix encryption test race condition with parallel tests
|
||||
- OpenWebUI provider connection and missing command registrations
|
||||
|
||||
### Features
|
||||
- Add image attachment support with PII detection
|
||||
|
||||
## [0.2.42] — 2026-04-07
|
||||
|
||||
### Documentation
|
||||
- Add AGENTS.md and SECURITY_AUDIT.md
|
||||
|
||||
## [0.2.41] — 2026-04-07
|
||||
|
||||
### Bug Fixes
|
||||
- **db,auth**: Auto-generate encryption keys for release builds
|
||||
- **lint**: Use inline format args in auth.rs
|
||||
- **lint**: Resolve all clippy warnings for CI compliance
|
||||
- **fmt**: Apply rustfmt formatting to webview_fetch.rs
|
||||
- **types**: Replace normalizeApiFormat() calls with direct value
|
||||
|
||||
### Documentation
|
||||
- **architecture**: Add C4 diagrams, ADRs, and architecture overview
|
||||
|
||||
### Features
|
||||
- **ai**: Add tool-calling and integration search as AI data source
|
||||
|
||||
## [0.2.40] — 2026-04-06
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Remove explicit docker.sock mount — act_runner mounts it automatically
|
||||
|
||||
## [0.2.36] — 2026-04-06
|
||||
|
||||
### Features
|
||||
- **ci**: Add persistent pre-baked Docker builder images
|
||||
|
||||
## [0.2.35] — 2026-04-06
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Skip Ollama download on macOS build — runner has no access to GitHub binary assets
|
||||
- **ci**: Remove all Ollama bundle download steps — use UI download button instead
|
||||
|
||||
### Refactoring
|
||||
- **ollama**: Remove download/install buttons — show plain install instructions only
|
||||
|
||||
## [0.2.34] — 2026-04-06
|
||||
|
||||
### Bug Fixes
|
||||
- **security**: Add path canonicalization and actionable permission error in install_ollama_from_bundle
|
||||
|
||||
### Features
|
||||
- **ui**: Fix model dropdown, auth prefill, PII persistence, theme toggle, and Ollama bundle
|
||||
|
||||
## [0.2.33] — 2026-04-05
|
||||
|
||||
### Features
|
||||
- **rebrand**: Rename binary to trcaa and auto-generate DB key
|
||||
|
||||
## [0.2.32] — 2026-04-05
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Restrict arm64 bundles to deb,rpm — skip AppImage
|
||||
|
||||
## [0.2.31] — 2026-04-05
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Set APPIMAGE_EXTRACT_AND_RUN=1 for arm64 AppImage bundling
|
||||
|
||||
## [0.2.30] — 2026-04-05
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Add make to arm64 host tools for OpenSSL vendored build
|
||||
|
||||
## [0.2.28] — 2026-04-05
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Use POSIX dot instead of source in arm64 build step
|
||||
|
||||
## [0.2.27] — 2026-04-05
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Remove GITHUB_PATH append that was breaking arm64 install step
|
||||
|
||||
## [0.2.26] — 2026-04-05
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Switch build-linux-arm64 to Ubuntu 22.04 with ports mirror
|
||||
|
||||
### Documentation
|
||||
- Update CI pipeline wiki and add ticket summary for arm64 fix
|
||||
|
||||
## [0.2.25] — 2026-04-05
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Rebuild apt sources with per-arch entries before arm64 cross-compile install
|
||||
- **ci**: Add workflow_dispatch and concurrency guard to auto-tag
|
||||
- **ci**: Replace heredoc with printf in arm64 install step
|
||||
|
||||
## [0.2.24] — 2026-04-05
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Fix arm64 cross-compile, drop cargo install tauri-cli, move wiki-sync
|
||||
|
||||
## [0.2.23] — 2026-04-05
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Unblock release jobs and namespace linux artifacts by arch
|
||||
- **security**: Harden secret handling and audit integrity
|
||||
- **pii**: Remove lookahead from hostname regex, fix fmt in analysis test
|
||||
- **security**: Enforce PII redaction before AI log transmission
|
||||
- **ci**: Unblock release jobs and namespace linux artifacts by arch
|
||||
|
||||
## [0.2.22] — 2026-04-05
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Run linux arm release natively and enforce arm artifacts
|
||||
|
||||
## [0.2.21] — 2026-04-05
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Force explicit linux arm64 target for release artifacts
|
||||
|
||||
## [0.2.20] — 2026-04-05
|
||||
|
||||
### Refactoring
|
||||
- **ci**: Remove standalone release workflow
|
||||
|
||||
## [0.2.19] — 2026-04-05
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Guarantee release jobs run after auto-tag
|
||||
- **ci**: Use stable auto-tag job outputs for release fanout
|
||||
- **ci**: Run post-tag release builds without job-output gating
|
||||
- **ci**: Repair auto-tag workflow yaml so jobs trigger
|
||||
|
||||
## [0.2.18] — 2026-04-05
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Trigger release workflow from auto-tag pushes
|
||||
|
||||
## [0.2.17] — 2026-04-05
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Harden release asset uploads for reruns
|
||||
|
||||
## [0.2.16] — 2026-04-05
|
||||
|
||||
### Bug Fixes
|
||||
- **ci**: Make release artifacts reliable across platforms
|
||||
|
||||
## [0.2.14] — 2026-04-04
|
||||
|
||||
### Bug Fixes
|
||||
- Resolve macOS bundle path after app rename
|
||||
|
||||
## [0.2.13] — 2026-04-04
|
||||
|
||||
### Bug Fixes
|
||||
- Resolve clippy uninlined_format_args in integrations and related modules
|
||||
- Resolve clippy format-args failures and OpenSSL vendoring issue
|
||||
|
||||
### Features
|
||||
- Add custom_rest provider mode and rebrand application name
|
||||
|
||||
## [0.2.12] — 2026-04-04
|
||||
|
||||
### Bug Fixes
|
||||
- ARM64 build uses native target instead of cross-compile
|
||||
|
||||
## [0.2.11] — 2026-04-04
|
||||
|
||||
### Bug Fixes
|
||||
- Persist integration settings and implement persistent browser windows
|
||||
|
||||
## [0.2.10] — 2026-04-03
|
||||
|
||||
### Features
|
||||
- Complete webview cookie extraction implementation
|
||||
|
||||
## [0.2.9] — 2026-04-03
|
||||
|
||||
### Features
|
||||
- Add multi-mode authentication for integrations (v0.2.10)
|
||||
|
||||
## [0.2.8] — 2026-04-03
|
||||
|
||||
### Features
|
||||
- Add temperature and max_tokens support for Custom REST providers (v0.2.9)
|
||||
|
||||
## [0.2.7] — 2026-04-03
|
||||
|
||||
### Bug Fixes
|
||||
- Use Wiki secret for authenticated wiki sync (v0.2.8)
|
||||
|
||||
### Documentation
|
||||
- Update wiki for v0.2.6 - integrations and Custom REST provider
|
||||
|
||||
### Features
|
||||
- Add automatic wiki sync to CI workflow (v0.2.7)
|
||||
|
||||
## [0.2.6] — 2026-04-03
|
||||
|
||||
### Bug Fixes
|
||||
- Add user_id support and OAuth shell permission (v0.2.6)
|
||||
|
||||
## [0.2.5] — 2026-04-03
|
||||
|
||||
### Documentation
|
||||
- Add Custom REST provider documentation
|
||||
|
||||
### Features
|
||||
- Implement Confluence, ServiceNow, and Azure DevOps REST API clients
|
||||
- Add Custom REST provider support
|
||||
|
||||
## [0.2.4] — 2026-04-03
|
||||
|
||||
### Features
|
||||
- Implement OAuth2 token exchange and AES-256-GCM encryption
|
||||
- Add OAuth2 Tauri commands for integration authentication
|
||||
- Implement OAuth2 callback server with automatic token exchange
|
||||
- Add OAuth2 frontend UI and complete integration flow
|
||||
|
||||
## [0.2.3] — 2026-04-03
|
||||
|
||||
### Bug Fixes
|
||||
- Improve Cancel button contrast in AI disclaimer modal
|
||||
|
||||
### Features
|
||||
- Add database schema for integration credentials and config
|
||||
|
||||
## [0.2.1] — 2026-04-03
|
||||
|
||||
### Bug Fixes
|
||||
- Implement native DOCX export without pandoc dependency
|
||||
|
||||
### Features
|
||||
- Add AI disclaimer modal before creating new issues
|
||||
|
||||
## [0.1.0] — 2026-04-03
|
||||
|
||||
### Bug Fixes
|
||||
- Resolve all clippy lints (uninlined format args, range::contains, push_str single chars)
|
||||
- Inline format args for Rust 1.88 clippy compatibility
|
||||
- Retain GPU-VRAM-eligible models in recommender even when RAM is low
|
||||
- Use alpine/git with explicit checkout for tag-based release builds
|
||||
- Set CI=true for cargo tauri build — Woodpecker sets CI=woodpecker which Tauri CLI rejects
|
||||
- Arm64 cross-compilation — add multiarch pkg-config sysroot setup
|
||||
- Remove arm64 from release pipeline — webkit2gtk multiarch conflict on x86_64 host
|
||||
- Write artifacts to workspace (shared between steps), not /artifacts/
|
||||
- Upload step needs gogs_default network to reach Gogs API (host firewall blocks default bridge)
|
||||
- Use bundled-sqlcipher-vendored-openssl for portable Windows cross-compilation
|
||||
- Add make to windows build step (required by vendored OpenSSL)
|
||||
- Replace empty icon placeholder files with real app icons
|
||||
- Suppress MinGW auto-export to resolve Windows DLL ordinal overflow
|
||||
- Use when: platform: for arm64 step routing (Woodpecker 0.15.4 compat)
|
||||
- Remove unused tauri-plugin-cli causing startup crash
|
||||
- Use $GITHUB_REF_NAME env var instead of ${{ github.ref_name }} expression
|
||||
- Remove unused tauri-plugin-updater + SQLCipher 16KB page size
|
||||
- Prevent WebKit/GTK system theme from overriding input text colors on Linux
|
||||
- Set SQLCipher cipher_page_size BEFORE first database access
|
||||
- Button text visibility, toggle contrast, create_issue IPC, ad-hoc codesign
|
||||
- Dropdown text invisible on macOS + correct codesign order for DMG
|
||||
- Add explicit text-foreground to SelectTrigger, SelectValue, and SelectItem
|
||||
- Ollama detection, install guide UI, and AI Providers auto-fill
|
||||
- Provider test FK error, model pull white screen, RECOMMENDED badge
|
||||
- Provider routing uses provider_type, Active badge, fmt
|
||||
- Navigate to /logs after issue creation, fix dashboard category display
|
||||
- Dashboard shows — while loading, exposes errors, adds refresh button
|
||||
- ListIssuesCmd was sending {query} but Rust expects {filter} — caused dashboard to always show 0 open issues
|
||||
- Arm64 linux cross-compilation — add multiarch and pkg-config env vars
|
||||
- Close from chat works before issue loads; save user reason as resolution step; dynamic version
|
||||
- DomainPrompts closing brace too early; arm64 use native platform image
|
||||
- UI contrast issues and ARM64 build failure
|
||||
- Remove Woodpecker CI and fix Gitea Actions ARM64 build
|
||||
- UI visibility issues, export errors, filtering, and audit log enhancement
|
||||
- ARM64 build native compilation instead of cross-compilation
|
||||
- Improve release artifact upload error handling
|
||||
- Install jq in Linux/Windows build containers
|
||||
- Improve download button visibility and add DOCX export
|
||||
|
||||
### Documentation
|
||||
- Update PLAN.md with accurate implementation status
|
||||
- Add CLAUDE.md with development guidance
|
||||
- Add wiki source files and CI auto-sync pipeline
|
||||
- Update PLAN.md - Phase 11 complete, redact token references
|
||||
- Update README and wiki for v0.1.0-alpha release
|
||||
- Remove broken arm64 CI step, document Woodpecker 0.15.4 limitation
|
||||
- Update README and wiki for Gitea Actions migration
|
||||
- Update README, wiki, and UI version to v0.1.1
|
||||
- Add LiteLLM + AWS Bedrock integration guide
|
||||
|
||||
### Features
|
||||
- Initial implementation of TFTSR IT Triage & RCA application
|
||||
- Add Windows amd64 cross-compile to release pipeline; add arm64 QEMU agent
|
||||
- Add native linux/arm64 release build step
|
||||
- Add macOS arm64 act_runner and release build job
|
||||
- Auto-increment patch tag on every merge to master
|
||||
- Inline file/screenshot attachment in triage chat
|
||||
- Close issues, restore history, auto-save resolution steps
|
||||
- Expand domains to 13 — add Telephony, Security/Vault, Public Safety, Application, Automation/CI-CD
|
||||
- Add HPE, Dell, Identity domains + expand k8s/security/observability/VESTA NXT
|
||||
|
||||
### Security
|
||||
- Rotate exposed token, redact from PLAN.md, add secret patterns to .gitignore
|
||||
|
||||
|
||||
41
cliff.toml
Normal file
41
cliff.toml
Normal file
@ -0,0 +1,41 @@
|
||||
[changelog]
|
||||
header = """
|
||||
# Changelog
|
||||
|
||||
All notable changes to TFTSR are documented here.
|
||||
Commit types shown: feat, fix, perf, docs, refactor.
|
||||
CI, chore, and build changes are excluded.
|
||||
|
||||
"""
|
||||
body = """
|
||||
{% if version -%}
|
||||
## [{{ version | trim_start_matches(pat="v") }}] — {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
|
||||
{% else -%}
|
||||
## [Unreleased]
|
||||
|
||||
{% endif -%}
|
||||
{% for group, commits in commits | group_by(attribute="group") -%}
|
||||
### {{ group | upper_first }}
|
||||
{% for commit in commits -%}
|
||||
- {% if commit.scope %}**{{ commit.scope }}**: {% endif %}{{ commit.message | upper_first }}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
"""
|
||||
footer = ""
|
||||
trim = true
|
||||
|
||||
[git]
|
||||
conventional_commits = true
|
||||
filter_unconventional = true
|
||||
tag_pattern = "v[0-9].*"
|
||||
ignore_tags = "rc|alpha|beta"
|
||||
sort_commits = "oldest"
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "Features" },
|
||||
{ message = "^fix", group = "Bug Fixes" },
|
||||
{ message = "^perf", group = "Performance" },
|
||||
{ message = "^docs", group = "Documentation" },
|
||||
{ message = "^refactor", group = "Refactoring" },
|
||||
{ message = "^ci|^chore|^build|^test|^style", skip = true },
|
||||
]
|
||||
@ -257,6 +257,52 @@ UPDATE protect_branch SET protected=true, require_pull_request=true WHERE repo_i
|
||||
|
||||
---
|
||||
|
||||
## Changelog Generation
|
||||
|
||||
Changelogs are generated automatically by **git-cliff** on every release.
|
||||
Configuration lives in `cliff.toml` at the repo root.
|
||||
|
||||
### How it works
|
||||
|
||||
A `changelog` job in `auto-tag.yml` runs in parallel with the build jobs, immediately
|
||||
after `autotag` completes:
|
||||
|
||||
1. Clones the full repo history with all tags (`--depth=2147483647` — git-cliff needs
|
||||
every tag to compute version boundaries).
|
||||
2. Downloads the git-cliff v2.7.0 static musl binary (~5 MB, no image change needed).
|
||||
3. Runs `git-cliff --output CHANGELOG.md` to regenerate the full cumulative changelog.
|
||||
4. Runs `git-cliff --latest --strip all` to produce release notes for the new tag only.
|
||||
5. PATCHes the Gitea release body with those notes (replaces the static `"Release vX.Y.Z"`).
|
||||
6. Commits `CHANGELOG.md` to master with `[skip ci]` appended to the message.
|
||||
The `[skip ci]` token prevents `auto-tag.yml` from re-triggering on the CHANGELOG commit.
|
||||
7. Uploads `CHANGELOG.md` as a release asset (replaces any previous version).
|
||||
|
||||
### cliff.toml reference
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| `tag_pattern` | `v[0-9].*` |
|
||||
| `ignore_tags` | `rc\|alpha\|beta` |
|
||||
| `filter_unconventional` | `true` — non-conventional commits are dropped |
|
||||
| Included types | `feat`, `fix`, `perf`, `docs`, `refactor` |
|
||||
| Excluded types | `ci`, `chore`, `build`, `test`, `style` |
|
||||
|
||||
### Loop prevention
|
||||
|
||||
The `[skip ci]` suffix on the CHANGELOG commit message is recognised by Gitea Actions
|
||||
and causes the workflow to be skipped for that push. Without it, the CHANGELOG commit
|
||||
would trigger `auto-tag.yml` again, incrementing the patch version forever.
|
||||
|
||||
### Bootstrap
|
||||
|
||||
The initial `CHANGELOG.md` was generated locally before the first PR:
|
||||
```sh
|
||||
git-cliff --config cliff.toml --output CHANGELOG.md
|
||||
```
|
||||
Subsequent runs are fully automated by CI.
|
||||
|
||||
---
|
||||
|
||||
## Known Issues & Fixes
|
||||
|
||||
### Debian Multiarch Breaks arm64 Cross-Compile (`held broken packages`)
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "tftsr",
|
||||
"private": true,
|
||||
"version": "0.2.50",
|
||||
"version": "0.2.62",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"version:update": "node scripts/update-version.mjs",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest",
|
||||
|
||||
111
scripts/update-version.mjs
Normal file
111
scripts/update-version.mjs
Normal file
@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const projectRoot = resolve(__dirname, '..');
|
||||
|
||||
/**
|
||||
* Validate version is semver-compliant (X.Y.Z)
|
||||
*/
|
||||
function isValidSemver(version) {
|
||||
return /^[0-9]+\.[0-9]+\.[0-9]+$/.test(version);
|
||||
}
|
||||
|
||||
function validateGitRepo(root) {
|
||||
if (!existsSync(resolve(root, '.git'))) {
|
||||
throw new Error(`Not a Git repository: ${root}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getVersionFromGit() {
|
||||
validateGitRepo(projectRoot);
|
||||
try {
|
||||
const output = execSync('git describe --tags --abbrev=0', {
|
||||
encoding: 'utf-8',
|
||||
cwd: projectRoot,
|
||||
shell: false
|
||||
});
|
||||
let version = output.trim();
|
||||
|
||||
// Remove v prefix
|
||||
version = version.replace(/^v/, '');
|
||||
|
||||
// Validate it's a valid semver
|
||||
if (!isValidSemver(version)) {
|
||||
const pkgJsonVersion = getFallbackVersion();
|
||||
console.warn(`Invalid version format "${version}" from git describe, using package.json fallback: ${pkgJsonVersion}`);
|
||||
return pkgJsonVersion;
|
||||
}
|
||||
|
||||
return version;
|
||||
} catch (e) {
|
||||
const pkgJsonVersion = getFallbackVersion();
|
||||
console.warn(`Failed to get version from Git tags, using package.json fallback: ${pkgJsonVersion}`);
|
||||
return pkgJsonVersion;
|
||||
}
|
||||
}
|
||||
|
||||
function getFallbackVersion() {
|
||||
const pkgPath = resolve(projectRoot, 'package.json');
|
||||
if (!existsSync(pkgPath)) {
|
||||
return '0.2.50';
|
||||
}
|
||||
try {
|
||||
const content = readFileSync(pkgPath, 'utf-8');
|
||||
const json = JSON.parse(content);
|
||||
return json.version || '0.2.50';
|
||||
} catch {
|
||||
return '0.2.50';
|
||||
}
|
||||
}
|
||||
|
||||
function updatePackageJson(version) {
|
||||
const fullPath = resolve(projectRoot, 'package.json');
|
||||
if (!existsSync(fullPath)) {
|
||||
throw new Error(`File not found: ${fullPath}`);
|
||||
}
|
||||
|
||||
const content = readFileSync(fullPath, 'utf-8');
|
||||
const json = JSON.parse(content);
|
||||
json.version = version;
|
||||
|
||||
// Write with 2-space indentation
|
||||
writeFileSync(fullPath, JSON.stringify(json, null, 2) + '\n', 'utf-8');
|
||||
console.log(`✓ Updated package.json to ${version}`);
|
||||
}
|
||||
|
||||
function updateTOML(path, version) {
|
||||
const fullPath = resolve(projectRoot, path);
|
||||
if (!existsSync(fullPath)) {
|
||||
throw new Error(`File not found: ${fullPath}`);
|
||||
}
|
||||
|
||||
const content = readFileSync(fullPath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const output = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.match(/^\s*version\s*=\s*"/)) {
|
||||
output.push(`version = "${version}"`);
|
||||
} else {
|
||||
output.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(fullPath, output.join('\n') + '\n', 'utf-8');
|
||||
console.log(`✓ Updated ${path} to ${version}`);
|
||||
}
|
||||
|
||||
const version = getVersionFromGit();
|
||||
console.log(`Setting version to: ${version}`);
|
||||
|
||||
updatePackageJson(version);
|
||||
updateTOML('src-tauri/Cargo.toml', version);
|
||||
updateTOML('src-tauri/tauri.conf.json', version);
|
||||
|
||||
console.log(`✓ All version fields updated to ${version}`);
|
||||
3
src-tauri/Cargo.lock
generated
3
src-tauri/Cargo.lock
generated
@ -6139,7 +6139,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "trcaa"
|
||||
version = "0.2.50"
|
||||
version = "0.2.62"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@ -6174,6 +6174,7 @@ dependencies = [
|
||||
"tokio-test",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"warp",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "trcaa"
|
||||
version = "0.2.50"
|
||||
version = "0.2.62"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@ -44,6 +44,7 @@ lazy_static = "1.4"
|
||||
warp = "0.3"
|
||||
urlencoding = "2"
|
||||
infer = "0.15"
|
||||
url = "2.5.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
@ -52,3 +53,7 @@ mockito = "1.2"
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
strip = true
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,30 @@
|
||||
fn main() {
|
||||
let version = get_version_from_git();
|
||||
|
||||
println!("cargo:rustc-env=APP_VERSION={version}");
|
||||
println!("cargo:rerun-if-changed=.git/refs/heads/master");
|
||||
println!("cargo:rerun-if-changed=.git/refs/tags");
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
fn get_version_from_git() -> String {
|
||||
if let Ok(output) = std::process::Command::new("git")
|
||||
.arg("describe")
|
||||
.arg("--tags")
|
||||
.arg("--abbrev=0")
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let version = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.trim_start_matches('v')
|
||||
.to_string();
|
||||
if !version.is_empty() {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"0.2.50".to_string()
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ use crate::ollama::{
|
||||
OllamaStatus,
|
||||
};
|
||||
use crate::state::{AppSettings, AppState, ProviderConfig};
|
||||
use std::env;
|
||||
|
||||
// --- Ollama commands ---
|
||||
|
||||
@ -275,3 +276,11 @@ pub async fn delete_ai_provider(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the application version from build-time environment
|
||||
#[tauri::command]
|
||||
pub async fn get_app_version() -> Result<String, String> {
|
||||
env::var("APP_VERSION")
|
||||
.or_else(|_| env::var("CARGO_PKG_VERSION"))
|
||||
.map_err(|e| format!("Failed to get version: {e}"))
|
||||
}
|
||||
|
||||
@ -191,6 +191,14 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> {
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);",
|
||||
),
|
||||
(
|
||||
"015_add_use_datastore_upload",
|
||||
"ALTER TABLE ai_providers ADD COLUMN use_datastore_upload INTEGER DEFAULT 0",
|
||||
),
|
||||
(
|
||||
"016_add_created_at",
|
||||
"ALTER TABLE ai_providers ADD COLUMN created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))",
|
||||
),
|
||||
];
|
||||
|
||||
for (name, sql) in migrations {
|
||||
@ -201,10 +209,27 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> {
|
||||
|
||||
if !already_applied {
|
||||
// FTS5 virtual table creation can be skipped if FTS5 is not compiled in
|
||||
if let Err(e) = conn.execute_batch(sql) {
|
||||
if name.contains("fts") {
|
||||
// Also handle column-already-exists errors for migrations 015-016
|
||||
if name.contains("fts") {
|
||||
if let Err(e) = conn.execute_batch(sql) {
|
||||
tracing::warn!("FTS5 not available, skipping: {e}");
|
||||
} else {
|
||||
}
|
||||
} else if name.ends_with("_add_use_datastore_upload")
|
||||
|| name.ends_with("_add_created_at")
|
||||
{
|
||||
// Use execute for ALTER TABLE (SQLite only allows one statement per command)
|
||||
// Skip error if column already exists (SQLITE_ERROR with "duplicate column name")
|
||||
if let Err(e) = conn.execute(sql, []) {
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("duplicate column name") {
|
||||
tracing::info!("Column may already exist, skipping migration {name}: {e}");
|
||||
} else {
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use execute_batch for other migrations (FTS5, CREATE TABLE, etc.)
|
||||
if let Err(e) = conn.execute_batch(sql) {
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
@ -560,4 +585,117 @@ mod tests {
|
||||
assert_eq!(encrypted_key, "encrypted_key_123");
|
||||
assert_eq!(model, "gpt-4o");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_missing_columns_to_existing_table() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
|
||||
// Simulate existing table without use_datastore_upload and created_at
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS ai_providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
provider_type TEXT NOT NULL,
|
||||
api_url TEXT NOT NULL,
|
||||
encrypted_api_key TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
max_tokens INTEGER,
|
||||
temperature REAL,
|
||||
custom_endpoint_path TEXT,
|
||||
custom_auth_header TEXT,
|
||||
custom_auth_prefix TEXT,
|
||||
api_format TEXT,
|
||||
user_id TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Verify columns BEFORE migration
|
||||
let mut stmt = conn.prepare("PRAGMA table_info(ai_providers)").unwrap();
|
||||
let columns: Vec<String> = stmt
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
|
||||
assert!(columns.contains(&"name".to_string()));
|
||||
assert!(columns.contains(&"model".to_string()));
|
||||
assert!(!columns.contains(&"use_datastore_upload".to_string()));
|
||||
assert!(!columns.contains(&"created_at".to_string()));
|
||||
|
||||
// Run migrations (should apply 015 to add missing columns)
|
||||
run_migrations(&conn).unwrap();
|
||||
|
||||
// Verify columns AFTER migration
|
||||
let mut stmt = conn.prepare("PRAGMA table_info(ai_providers)").unwrap();
|
||||
let columns: Vec<String> = stmt
|
||||
.query_map([], |row| row.get::<_, String>(1))
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
|
||||
assert!(columns.contains(&"name".to_string()));
|
||||
assert!(columns.contains(&"model".to_string()));
|
||||
assert!(columns.contains(&"use_datastore_upload".to_string()));
|
||||
assert!(columns.contains(&"created_at".to_string()));
|
||||
|
||||
// Verify data integrity - existing rows should have default values
|
||||
conn.execute(
|
||||
"INSERT INTO ai_providers (id, name, provider_type, api_url, encrypted_api_key, model)
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
rusqlite::params![
|
||||
"test-provider-2",
|
||||
"Test Provider",
|
||||
"openai",
|
||||
"https://api.example.com",
|
||||
"encrypted_key_456",
|
||||
"gpt-3.5-turbo"
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (name, use_datastore_upload, created_at): (String, bool, String) = conn
|
||||
.query_row(
|
||||
"SELECT name, use_datastore_upload, created_at FROM ai_providers WHERE name = ?1",
|
||||
["Test Provider"],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(name, "Test Provider");
|
||||
assert!(!use_datastore_upload);
|
||||
assert!(created_at.len() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_idempotent_add_missing_columns() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
|
||||
// Create table with both columns already present (simulating prior migration run)
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS ai_providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
provider_type TEXT NOT NULL,
|
||||
api_url TEXT NOT NULL,
|
||||
encrypted_api_key TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
max_tokens INTEGER,
|
||||
temperature REAL,
|
||||
custom_endpoint_path TEXT,
|
||||
custom_auth_header TEXT,
|
||||
custom_auth_prefix TEXT,
|
||||
api_format TEXT,
|
||||
user_id TEXT,
|
||||
use_datastore_upload INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Should not fail even though columns already exist
|
||||
run_migrations(&conn).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,40 @@
|
||||
use super::confluence_search::SearchResult;
|
||||
use crate::integrations::query_expansion::expand_query;
|
||||
|
||||
const MAX_EXPANDED_QUERIES: usize = 3;
|
||||
|
||||
fn escape_wiql(s: &str) -> String {
|
||||
s.replace('\'', "''")
|
||||
.replace('"', "\\\"")
|
||||
.replace('\\', "\\\\")
|
||||
.replace('(', "\\(")
|
||||
.replace(')', "\\)")
|
||||
.replace(';', "\\;")
|
||||
.replace('=', "\\=")
|
||||
}
|
||||
|
||||
/// Basic HTML tag stripping to prevent XSS in excerpts
|
||||
fn strip_html_tags(html: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut in_tag = false;
|
||||
|
||||
for ch in html.chars() {
|
||||
match ch {
|
||||
'<' => in_tag = true,
|
||||
'>' => in_tag = false,
|
||||
_ if !in_tag => result.push(ch),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up whitespace
|
||||
result
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.trim()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Search Azure DevOps Wiki for content matching the query
|
||||
pub async fn search_wiki(
|
||||
@ -10,90 +46,94 @@ pub async fn search_wiki(
|
||||
let cookie_header = crate::integrations::webview_auth::cookies_to_header(cookies);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Use Azure DevOps Search API
|
||||
let search_url = format!(
|
||||
"{}/_apis/search/wikisearchresults?api-version=7.0",
|
||||
org_url.trim_end_matches('/')
|
||||
);
|
||||
let expanded_queries = expand_query(query);
|
||||
|
||||
let search_body = serde_json::json!({
|
||||
"searchText": query,
|
||||
"$top": 5,
|
||||
"filters": {
|
||||
"ProjectFilters": [project]
|
||||
let mut all_results = Vec::new();
|
||||
|
||||
for expanded_query in expanded_queries.iter().take(MAX_EXPANDED_QUERIES) {
|
||||
// Use Azure DevOps Search API
|
||||
let search_url = format!(
|
||||
"{}/_apis/search/wikisearchresults?api-version=7.0",
|
||||
org_url.trim_end_matches('/')
|
||||
);
|
||||
|
||||
let search_body = serde_json::json!({
|
||||
"searchText": expanded_query,
|
||||
"$top": 5,
|
||||
"filters": {
|
||||
"ProjectFilters": [project]
|
||||
}
|
||||
});
|
||||
|
||||
tracing::info!("Searching Azure DevOps Wiki with query: {}", expanded_query);
|
||||
|
||||
let resp = client
|
||||
.post(&search_url)
|
||||
.header("Cookie", &cookie_header)
|
||||
.header("Accept", "application/json")
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&search_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Azure DevOps wiki search failed: {e}"))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
tracing::warn!("Azure DevOps wiki search failed with status {status}: {text}");
|
||||
continue;
|
||||
}
|
||||
});
|
||||
|
||||
tracing::info!("Searching Azure DevOps Wiki: {}", search_url);
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse ADO wiki search response: {e}"))?;
|
||||
|
||||
let resp = client
|
||||
.post(&search_url)
|
||||
.header("Cookie", &cookie_header)
|
||||
.header("Accept", "application/json")
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&search_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Azure DevOps wiki search failed: {e}"))?;
|
||||
if let Some(results_array) = json["results"].as_array() {
|
||||
for item in results_array.iter().take(MAX_EXPANDED_QUERIES) {
|
||||
let title = item["fileName"].as_str().unwrap_or("Untitled").to_string();
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"Azure DevOps wiki search failed with status {status}: {text}"
|
||||
));
|
||||
}
|
||||
let path = item["path"].as_str().unwrap_or("");
|
||||
let url = format!(
|
||||
"{}/_wiki/wikis/{}/{}",
|
||||
org_url.trim_end_matches('/'),
|
||||
project,
|
||||
path
|
||||
);
|
||||
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse ADO wiki search response: {e}"))?;
|
||||
let excerpt = strip_html_tags(item["content"].as_str().unwrap_or(""))
|
||||
.chars()
|
||||
.take(300)
|
||||
.collect::<String>();
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
if let Some(results_array) = json["results"].as_array() {
|
||||
for item in results_array.iter().take(3) {
|
||||
let title = item["fileName"].as_str().unwrap_or("Untitled").to_string();
|
||||
|
||||
let path = item["path"].as_str().unwrap_or("");
|
||||
let url = format!(
|
||||
"{}/_wiki/wikis/{}/{}",
|
||||
org_url.trim_end_matches('/'),
|
||||
project,
|
||||
path
|
||||
);
|
||||
|
||||
let excerpt = item["content"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take(300)
|
||||
.collect::<String>();
|
||||
|
||||
// Fetch full wiki page content
|
||||
let content = if let Some(wiki_id) = item["wiki"]["id"].as_str() {
|
||||
if let Some(page_path) = item["path"].as_str() {
|
||||
fetch_wiki_page(org_url, wiki_id, page_path, &cookie_header)
|
||||
.await
|
||||
.ok()
|
||||
// Fetch full wiki page content
|
||||
let content = if let Some(wiki_id) = item["wiki"]["id"].as_str() {
|
||||
if let Some(page_path) = item["path"].as_str() {
|
||||
fetch_wiki_page(org_url, wiki_id, page_path, &cookie_header)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
};
|
||||
|
||||
results.push(SearchResult {
|
||||
title,
|
||||
url,
|
||||
excerpt,
|
||||
content,
|
||||
source: "Azure DevOps".to_string(),
|
||||
});
|
||||
all_results.push(SearchResult {
|
||||
title,
|
||||
url,
|
||||
excerpt,
|
||||
content,
|
||||
source: "Azure DevOps".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
all_results.sort_by(|a, b| a.url.cmp(&b.url));
|
||||
all_results.dedup_by(|a, b| a.url == b.url);
|
||||
|
||||
Ok(all_results)
|
||||
}
|
||||
|
||||
/// Fetch full wiki page content
|
||||
@ -151,55 +191,68 @@ pub async fn search_work_items(
|
||||
let cookie_header = crate::integrations::webview_auth::cookies_to_header(cookies);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Use WIQL (Work Item Query Language)
|
||||
let wiql_url = format!(
|
||||
"{}/_apis/wit/wiql?api-version=7.0",
|
||||
org_url.trim_end_matches('/')
|
||||
);
|
||||
let expanded_queries = expand_query(query);
|
||||
|
||||
let wiql_query = format!(
|
||||
"SELECT [System.Id], [System.Title], [System.Description], [System.State] FROM WorkItems WHERE [System.TeamProject] = '{project}' AND ([System.Title] CONTAINS '{query}' OR [System.Description] CONTAINS '{query}') ORDER BY [System.ChangedDate] DESC"
|
||||
);
|
||||
let mut all_results = Vec::new();
|
||||
|
||||
let wiql_body = serde_json::json!({
|
||||
"query": wiql_query
|
||||
});
|
||||
for expanded_query in expanded_queries.iter().take(MAX_EXPANDED_QUERIES) {
|
||||
// Use WIQL (Work Item Query Language)
|
||||
let wiql_url = format!(
|
||||
"{}/_apis/wit/wiql?api-version=7.0",
|
||||
org_url.trim_end_matches('/')
|
||||
);
|
||||
|
||||
tracing::info!("Searching Azure DevOps work items");
|
||||
let safe_query = escape_wiql(expanded_query);
|
||||
let wiql_query = format!(
|
||||
"SELECT [System.Id], [System.Title], [System.Description], [System.State] FROM WorkItems WHERE [System.TeamProject] = '{project}' AND ([System.Title] ~ '{safe_query}' OR [System.Description] ~ '{safe_query}') ORDER BY [System.ChangedDate] DESC"
|
||||
);
|
||||
|
||||
let resp = client
|
||||
.post(&wiql_url)
|
||||
.header("Cookie", &cookie_header)
|
||||
.header("Accept", "application/json")
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&wiql_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("ADO work item search failed: {e}"))?;
|
||||
let wiql_body = serde_json::json!({
|
||||
"query": wiql_query
|
||||
});
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Ok(Vec::new()); // Don't fail if work item search fails
|
||||
}
|
||||
tracing::info!(
|
||||
"Searching Azure DevOps work items with query: {}",
|
||||
expanded_query
|
||||
);
|
||||
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|_| "Failed to parse work item response".to_string())?;
|
||||
let resp = client
|
||||
.post(&wiql_url)
|
||||
.header("Cookie", &cookie_header)
|
||||
.header("Accept", "application/json")
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&wiql_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("ADO work item search failed: {e}"))?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
if !resp.status().is_success() {
|
||||
continue; // Don't fail if work item search fails
|
||||
}
|
||||
|
||||
if let Some(work_items) = json["workItems"].as_array() {
|
||||
// Fetch details for top 3 work items
|
||||
for item in work_items.iter().take(3) {
|
||||
if let Some(id) = item["id"].as_i64() {
|
||||
if let Ok(work_item) = fetch_work_item_details(org_url, id, &cookie_header).await {
|
||||
results.push(work_item);
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|_| "Failed to parse work item response".to_string())?;
|
||||
|
||||
if let Some(work_items) = json["workItems"].as_array() {
|
||||
// Fetch details for top 3 work items
|
||||
for item in work_items.iter().take(MAX_EXPANDED_QUERIES) {
|
||||
if let Some(id) = item["id"].as_i64() {
|
||||
if let Ok(work_item) =
|
||||
fetch_work_item_details(org_url, id, &cookie_header).await
|
||||
{
|
||||
all_results.push(work_item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
all_results.sort_by(|a, b| a.url.cmp(&b.url));
|
||||
all_results.dedup_by(|a, b| a.url == b.url);
|
||||
|
||||
Ok(all_results)
|
||||
}
|
||||
|
||||
/// Fetch work item details
|
||||
@ -263,3 +316,53 @@ async fn fetch_work_item_details(
|
||||
source: "Azure DevOps".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_escape_wiql_escapes_single_quotes() {
|
||||
assert_eq!(escape_wiql("test'single"), "test''single");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_wiql_escapes_double_quotes() {
|
||||
assert_eq!(escape_wiql("test\"double"), "test\\\\\"double");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_wiql_escapes_backslash() {
|
||||
assert_eq!(escape_wiql("test\\backslash"), r#"test\\backslash"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_wiql_escapes_parens() {
|
||||
assert_eq!(escape_wiql("test(paren"), r#"test\(paren"#);
|
||||
assert_eq!(escape_wiql("test)paren"), r#"test\)paren"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_wiql_escapes_semicolon() {
|
||||
assert_eq!(escape_wiql("test;semi"), r#"test\;semi"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_wiql_escapes_equals() {
|
||||
assert_eq!(escape_wiql("test=equal"), r#"test\=equal"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_wiql_no_special_chars() {
|
||||
assert_eq!(escape_wiql("simple query"), "simple query");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_html_tags() {
|
||||
let html = "<p>Hello <strong>world</strong>!</p>";
|
||||
assert_eq!(strip_html_tags(html), "Hello world!");
|
||||
|
||||
let html2 = "<div><h1>Title</h1><p>Content</p></div>";
|
||||
assert_eq!(strip_html_tags(html2), "TitleContent");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use url::Url;
|
||||
|
||||
use super::query_expansion::expand_query;
|
||||
|
||||
const MAX_EXPANDED_QUERIES: usize = 3;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SearchResult {
|
||||
@ -6,10 +11,36 @@ pub struct SearchResult {
|
||||
pub url: String,
|
||||
pub excerpt: String,
|
||||
pub content: Option<String>,
|
||||
pub source: String, // "confluence", "servicenow", "azuredevops"
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
fn canonicalize_url(url: &str) -> String {
|
||||
Url::parse(url)
|
||||
.ok()
|
||||
.map(|u| {
|
||||
let mut u = u.clone();
|
||||
u.set_fragment(None);
|
||||
u.set_query(None);
|
||||
u.to_string()
|
||||
})
|
||||
.unwrap_or_else(|| url.to_string())
|
||||
}
|
||||
|
||||
fn escape_cql(s: &str) -> String {
|
||||
s.replace('"', "\\\"")
|
||||
.replace(')', "\\)")
|
||||
.replace('(', "\\(")
|
||||
.replace('~', "\\~")
|
||||
.replace('&', "\\&")
|
||||
.replace('|', "\\|")
|
||||
.replace('+', "\\+")
|
||||
.replace('-', "\\-")
|
||||
}
|
||||
|
||||
/// Search Confluence for content matching the query
|
||||
///
|
||||
/// This function expands the user query with related terms, synonyms, and variations
|
||||
/// to improve search coverage across Confluence spaces.
|
||||
pub async fn search_confluence(
|
||||
base_url: &str,
|
||||
query: &str,
|
||||
@ -18,86 +49,89 @@ pub async fn search_confluence(
|
||||
let cookie_header = crate::integrations::webview_auth::cookies_to_header(cookies);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Use Confluence CQL search
|
||||
let search_url = format!(
|
||||
"{}/rest/api/search?cql=text~\"{}\"&limit=5",
|
||||
base_url.trim_end_matches('/'),
|
||||
urlencoding::encode(query)
|
||||
);
|
||||
let expanded_queries = expand_query(query);
|
||||
|
||||
tracing::info!("Searching Confluence: {}", search_url);
|
||||
let mut all_results = Vec::new();
|
||||
|
||||
let resp = client
|
||||
.get(&search_url)
|
||||
.header("Cookie", &cookie_header)
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Confluence search request failed: {e}"))?;
|
||||
for expanded_query in expanded_queries.iter().take(MAX_EXPANDED_QUERIES) {
|
||||
let safe_query = escape_cql(expanded_query);
|
||||
let search_url = format!(
|
||||
"{}/rest/api/search?cql=text~\"{}\"&limit=5",
|
||||
base_url.trim_end_matches('/'),
|
||||
urlencoding::encode(&safe_query)
|
||||
);
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"Confluence search failed with status {status}: {text}"
|
||||
));
|
||||
}
|
||||
tracing::info!(
|
||||
"Searching Confluence with expanded query: {}",
|
||||
expanded_query
|
||||
);
|
||||
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse Confluence search response: {e}"))?;
|
||||
let resp = client
|
||||
.get(&search_url)
|
||||
.header("Cookie", &cookie_header)
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Confluence search request failed: {e}"))?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
tracing::warn!("Confluence search failed with status {status}: {text}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(results_array) = json["results"].as_array() {
|
||||
for item in results_array.iter().take(3) {
|
||||
// Take top 3 results
|
||||
let title = item["title"].as_str().unwrap_or("Untitled").to_string();
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse Confluence search response: {e}"))?;
|
||||
|
||||
let id = item["content"]["id"].as_str();
|
||||
let space_key = item["content"]["space"]["key"].as_str();
|
||||
if let Some(results_array) = json["results"].as_array() {
|
||||
for item in results_array.iter().take(MAX_EXPANDED_QUERIES) {
|
||||
let title = item["title"].as_str().unwrap_or("Untitled").to_string();
|
||||
|
||||
// Build URL
|
||||
let url = if let (Some(id_str), Some(space)) = (id, space_key) {
|
||||
format!(
|
||||
"{}/display/{}/{}",
|
||||
base_url.trim_end_matches('/'),
|
||||
space,
|
||||
id_str
|
||||
)
|
||||
} else {
|
||||
base_url.to_string()
|
||||
};
|
||||
let id = item["content"]["id"].as_str();
|
||||
let space_key = item["content"]["space"]["key"].as_str();
|
||||
|
||||
// Get excerpt from search result
|
||||
let excerpt = item["excerpt"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
.replace("<span class=\"highlight\">", "")
|
||||
.replace("</span>", "");
|
||||
let url = if let (Some(id_str), Some(space)) = (id, space_key) {
|
||||
format!(
|
||||
"{}/display/{}/{}",
|
||||
base_url.trim_end_matches('/'),
|
||||
space,
|
||||
id_str
|
||||
)
|
||||
} else {
|
||||
base_url.to_string()
|
||||
};
|
||||
|
||||
// Fetch full page content
|
||||
let content = if let Some(content_id) = id {
|
||||
fetch_page_content(base_url, content_id, &cookie_header)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let excerpt = strip_html_tags(item["excerpt"].as_str().unwrap_or(""))
|
||||
.chars()
|
||||
.take(300)
|
||||
.collect::<String>();
|
||||
|
||||
results.push(SearchResult {
|
||||
title,
|
||||
url,
|
||||
excerpt,
|
||||
content,
|
||||
source: "Confluence".to_string(),
|
||||
});
|
||||
let content = if let Some(content_id) = id {
|
||||
fetch_page_content(base_url, content_id, &cookie_header)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
all_results.push(SearchResult {
|
||||
title,
|
||||
url,
|
||||
excerpt,
|
||||
content,
|
||||
source: "Confluence".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
all_results.sort_by(|a, b| canonicalize_url(&a.url).cmp(&canonicalize_url(&b.url)));
|
||||
all_results.dedup_by(|a, b| canonicalize_url(&a.url) == canonicalize_url(&b.url));
|
||||
|
||||
Ok(all_results)
|
||||
}
|
||||
|
||||
/// Fetch full content of a Confluence page
|
||||
@ -185,4 +219,43 @@ mod tests {
|
||||
let html2 = "<div><h1>Title</h1><p>Content</p></div>";
|
||||
assert_eq!(strip_html_tags(html2), "TitleContent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_cql_escapes_special_chars() {
|
||||
assert_eq!(escape_cql("test\"quote"), r#"test\"quote"#);
|
||||
assert_eq!(escape_cql("test(paren"), r#"test\(paren"#);
|
||||
assert_eq!(escape_cql("test)paren"), r#"test\)paren"#);
|
||||
assert_eq!(escape_cql("test~tilde"), r#"test\~tilde"#);
|
||||
assert_eq!(escape_cql("test&and"), r#"test\&and"#);
|
||||
assert_eq!(escape_cql("test|or"), r#"test\|or"#);
|
||||
assert_eq!(escape_cql("test+plus"), r#"test\+plus"#);
|
||||
assert_eq!(escape_cql("test-minus"), r#"test\-minus"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_escape_cql_no_special_chars() {
|
||||
assert_eq!(escape_cql("simple query"), "simple query");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonicalize_url_removes_fragment() {
|
||||
assert_eq!(
|
||||
canonicalize_url("https://example.com/page#section"),
|
||||
"https://example.com/page"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonicalize_url_removes_query() {
|
||||
assert_eq!(
|
||||
canonicalize_url("https://example.com/page?param=value"),
|
||||
"https://example.com/page"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_canonicalize_url_handles_malformed() {
|
||||
// Malformed URLs fall back to original
|
||||
assert_eq!(canonicalize_url("not a url"), "not a url");
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ pub mod azuredevops_search;
|
||||
pub mod callback_server;
|
||||
pub mod confluence;
|
||||
pub mod confluence_search;
|
||||
pub mod query_expansion;
|
||||
pub mod servicenow;
|
||||
pub mod servicenow_search;
|
||||
pub mod webview_auth;
|
||||
|
||||
290
src-tauri/src/integrations/query_expansion.rs
Normal file
290
src-tauri/src/integrations/query_expansion.rs
Normal file
@ -0,0 +1,290 @@
|
||||
/// Query expansion module for integration search
|
||||
///
|
||||
/// This module provides functionality to expand user queries with related terms,
|
||||
/// synonyms, and variations to improve search results across integrations like
|
||||
/// Confluence, ServiceNow, and Azure DevOps.
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Product name synonyms for common product variations
|
||||
/// Maps common abbreviations/variants to their full names for search expansion
|
||||
fn get_product_synonyms(query: &str) -> Vec<String> {
|
||||
let mut synonyms = Vec::new();
|
||||
|
||||
// VESTA NXT related synonyms
|
||||
if query.to_lowercase().contains("vesta") || query.to_lowercase().contains("vnxt") {
|
||||
synonyms.extend(vec![
|
||||
"VESTA NXT".to_string(),
|
||||
"Vesta NXT".to_string(),
|
||||
"VNXT".to_string(),
|
||||
"vnxt".to_string(),
|
||||
"Vesta".to_string(),
|
||||
"vesta".to_string(),
|
||||
"VNX".to_string(),
|
||||
"vnx".to_string(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Version number patterns (e.g., 1.0.12, 1.1.9)
|
||||
if query.contains('.') {
|
||||
// Extract version-like patterns and add variations
|
||||
let version_parts: Vec<&str> = query.split('.').collect();
|
||||
if version_parts.len() >= 2 {
|
||||
// Add variations without dots
|
||||
let version_no_dots = version_parts.join("");
|
||||
synonyms.push(version_no_dots);
|
||||
|
||||
// Add partial versions
|
||||
if version_parts.len() >= 2 {
|
||||
synonyms.push(version_parts[0..2].join("."));
|
||||
}
|
||||
if version_parts.len() >= 3 {
|
||||
synonyms.push(version_parts[0..3].join("."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Common upgrade-related terms
|
||||
if query.to_lowercase().contains("upgrade") || query.to_lowercase().contains("update") {
|
||||
synonyms.extend(vec![
|
||||
"upgrade".to_string(),
|
||||
"update".to_string(),
|
||||
"migration".to_string(),
|
||||
"patch".to_string(),
|
||||
"version".to_string(),
|
||||
"install".to_string(),
|
||||
"installation".to_string(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Remove duplicates and empty strings
|
||||
synonyms.sort();
|
||||
synonyms.dedup();
|
||||
synonyms.retain(|s| !s.is_empty());
|
||||
|
||||
synonyms
|
||||
}
|
||||
|
||||
/// Expand a search query with related terms for better search coverage
|
||||
///
|
||||
/// This function takes a user query and expands it with:
|
||||
/// - Product name synonyms (e.g., "VNXT" -> "VESTA NXT", "Vesta NXT")
|
||||
/// - Version number variations
|
||||
/// - Related terms based on query content
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `query` - The original user query
|
||||
///
|
||||
/// # Returns
|
||||
/// A vector of query strings to search, with the original query first
|
||||
/// followed by expanded variations. Returns empty only if input is empty or
|
||||
/// whitespace-only. Otherwise, always returns at least the original query.
|
||||
pub fn expand_query(query: &str) -> Vec<String> {
|
||||
if query.trim().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut expanded = vec![query.to_string()];
|
||||
|
||||
// Get product synonyms
|
||||
let product_synonyms = get_product_synonyms(query);
|
||||
expanded.extend(product_synonyms);
|
||||
|
||||
// Extract keywords from query for additional expansion
|
||||
let keywords = extract_keywords(query);
|
||||
|
||||
// Add keyword variations
|
||||
for keyword in keywords.iter().take(5) {
|
||||
if !expanded.contains(keyword) {
|
||||
expanded.push(keyword.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Add common related terms based on query content
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
if query_lower.contains("confluence") || query_lower.contains("documentation") {
|
||||
expanded.push("docs".to_string());
|
||||
expanded.push("manual".to_string());
|
||||
expanded.push("guide".to_string());
|
||||
}
|
||||
|
||||
if query_lower.contains("deploy") || query_lower.contains("deployment") {
|
||||
expanded.push("deploy".to_string());
|
||||
expanded.push("deployment".to_string());
|
||||
expanded.push("release".to_string());
|
||||
expanded.push("build".to_string());
|
||||
}
|
||||
|
||||
if query_lower.contains("kubernetes") || query_lower.contains("k8s") {
|
||||
expanded.push("kubernetes".to_string());
|
||||
expanded.push("k8s".to_string());
|
||||
expanded.push("pod".to_string());
|
||||
expanded.push("container".to_string());
|
||||
}
|
||||
|
||||
// Remove duplicates and empty strings
|
||||
expanded.sort();
|
||||
expanded.dedup();
|
||||
expanded.retain(|s| !s.is_empty());
|
||||
|
||||
expanded
|
||||
}
|
||||
|
||||
/// Extract important keywords from a search query
|
||||
///
|
||||
/// This function removes stop words and extracts meaningful terms
|
||||
/// for search expansion.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `query` - The original user query
|
||||
///
|
||||
/// # Returns
|
||||
/// A vector of extracted keywords
|
||||
fn extract_keywords(query: &str) -> Vec<String> {
|
||||
let stop_words: HashSet<&str> = [
|
||||
"how", "do", "i", "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
|
||||
"have", "has", "had", "having", "do", "does", "did", "doing", "will", "would", "should",
|
||||
"could", "can", "may", "might", "must", "to", "from", "in", "on", "at", "by", "for",
|
||||
"with", "about", "as", "of", "or", "and", "but", "not", "what", "when", "where", "which",
|
||||
"who", "this", "that", "these", "those", "if", "then", "else", "for", "while", "until",
|
||||
"against", "between", "into", "through", "during", "before", "after", "above", "below",
|
||||
"up", "down", "out", "off", "over", "under", "again", "further", "then", "once", "here",
|
||||
"there", "why", "where", "all", "any", "both", "each", "few", "more", "most", "other",
|
||||
"some", "such", "no", "nor", "only", "own", "same", "so", "than", "too", "very", "can",
|
||||
"just", "should", "now",
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let mut keywords = Vec::new();
|
||||
let mut remaining = query.to_string();
|
||||
|
||||
while !remaining.is_empty() {
|
||||
// Skip leading whitespace
|
||||
if remaining.starts_with(char::is_whitespace) {
|
||||
remaining = remaining.trim_start().to_string();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to extract version number (e.g., 1.0.12, 1.1.9)
|
||||
if remaining.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
let mut end_pos = 0;
|
||||
let mut dot_count = 0;
|
||||
|
||||
for (i, c) in remaining.chars().enumerate() {
|
||||
if c.is_ascii_digit() {
|
||||
end_pos = i + 1;
|
||||
} else if c == '.' {
|
||||
end_pos = i + 1;
|
||||
dot_count += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only extract if we have at least 2 dots (e.g., 1.0.12)
|
||||
if dot_count >= 2 && end_pos > 0 {
|
||||
let version = remaining[..end_pos].to_string();
|
||||
keywords.push(version.clone());
|
||||
remaining = remaining[end_pos..].to_string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Find word boundary - split on whitespace or non-alphanumeric
|
||||
let mut split_pos = remaining.len();
|
||||
for (i, c) in remaining.chars().enumerate() {
|
||||
if c.is_whitespace() || !c.is_alphanumeric() {
|
||||
split_pos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If split_pos is 0, the string starts with a non-alphanumeric character
|
||||
// Skip it and continue
|
||||
if split_pos == 0 {
|
||||
remaining = remaining[1..].to_string();
|
||||
continue;
|
||||
}
|
||||
|
||||
let word = remaining[..split_pos].to_lowercase();
|
||||
remaining = remaining[split_pos..].to_string();
|
||||
|
||||
// Skip empty words, single chars, and stop words
|
||||
if word.is_empty() || word.len() < 2 || stop_words.contains(word.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add numeric words with 3+ digits
|
||||
if word.chars().all(|c| c.is_ascii_digit()) && word.len() >= 3 {
|
||||
keywords.push(word.clone());
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add words with at least one alphabetic character
|
||||
if word.chars().any(|c| c.is_alphabetic()) {
|
||||
keywords.push(word.clone());
|
||||
}
|
||||
}
|
||||
|
||||
keywords.sort();
|
||||
keywords.dedup();
|
||||
|
||||
keywords
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_expand_query_with_product_synonyms() {
|
||||
let query = "upgrade vesta nxt to 1.1.9";
|
||||
let expanded = expand_query(query);
|
||||
|
||||
// Should contain original query
|
||||
assert!(expanded.contains(&query.to_string()));
|
||||
|
||||
// Should contain product synonyms
|
||||
assert!(expanded
|
||||
.iter()
|
||||
.any(|s| s.contains("vnxt") || s.contains("vnxt")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_query_with_version_numbers() {
|
||||
let query = "version 1.0.12";
|
||||
let expanded = expand_query(query);
|
||||
|
||||
// Should contain original query
|
||||
assert!(expanded.contains(&query.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_keywords() {
|
||||
let query = "How do I upgrade VESTA NXT from 1.0.12 to 1.1.9?";
|
||||
let keywords = extract_keywords(query);
|
||||
|
||||
assert!(keywords.contains(&"upgrade".to_string()));
|
||||
assert!(keywords.contains(&"vesta".to_string()));
|
||||
assert!(keywords.contains(&"nxt".to_string()));
|
||||
assert!(keywords.contains(&"1.0.12".to_string()));
|
||||
assert!(keywords.contains(&"1.1.9".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_product_synonyms() {
|
||||
let synonyms = get_product_synonyms("vesta nxt upgrade");
|
||||
|
||||
// Should contain VNXT synonym
|
||||
assert!(synonyms
|
||||
.iter()
|
||||
.any(|s| s.contains("VNXT") || s.contains("vnxt")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_query() {
|
||||
let expanded = expand_query("");
|
||||
assert!(expanded.is_empty() || expanded.contains(&"".to_string()));
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,7 @@
|
||||
use super::confluence_search::SearchResult;
|
||||
use crate::integrations::query_expansion::expand_query;
|
||||
|
||||
const MAX_EXPANDED_QUERIES: usize = 3;
|
||||
|
||||
/// Search ServiceNow Knowledge Base for content matching the query
|
||||
pub async fn search_servicenow(
|
||||
@ -9,82 +12,88 @@ pub async fn search_servicenow(
|
||||
let cookie_header = crate::integrations::webview_auth::cookies_to_header(cookies);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Search Knowledge Base articles
|
||||
let search_url = format!(
|
||||
"{}/api/now/table/kb_knowledge?sysparm_query=textLIKE{}^ORshort_descriptionLIKE{}&sysparm_limit=5",
|
||||
instance_url.trim_end_matches('/'),
|
||||
urlencoding::encode(query),
|
||||
urlencoding::encode(query)
|
||||
);
|
||||
let expanded_queries = expand_query(query);
|
||||
|
||||
tracing::info!("Searching ServiceNow: {}", search_url);
|
||||
let mut all_results = Vec::new();
|
||||
|
||||
let resp = client
|
||||
.get(&search_url)
|
||||
.header("Cookie", &cookie_header)
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("ServiceNow search request failed: {e}"))?;
|
||||
for expanded_query in expanded_queries.iter().take(MAX_EXPANDED_QUERIES) {
|
||||
// Search Knowledge Base articles
|
||||
let search_url = format!(
|
||||
"{}/api/now/table/kb_knowledge?sysparm_query=textLIKE{}^ORshort_descriptionLIKE{}&sysparm_limit=5",
|
||||
instance_url.trim_end_matches('/'),
|
||||
urlencoding::encode(expanded_query),
|
||||
urlencoding::encode(expanded_query)
|
||||
);
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"ServiceNow search failed with status {status}: {text}"
|
||||
));
|
||||
}
|
||||
tracing::info!("Searching ServiceNow with query: {}", expanded_query);
|
||||
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse ServiceNow search response: {e}"))?;
|
||||
let resp = client
|
||||
.get(&search_url)
|
||||
.header("Cookie", &cookie_header)
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("ServiceNow search request failed: {e}"))?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
tracing::warn!("ServiceNow search failed with status {status}: {text}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(result_array) = json["result"].as_array() {
|
||||
for item in result_array.iter().take(3) {
|
||||
// Take top 3 results
|
||||
let title = item["short_description"]
|
||||
.as_str()
|
||||
.unwrap_or("Untitled")
|
||||
.to_string();
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse ServiceNow search response: {e}"))?;
|
||||
|
||||
let sys_id = item["sys_id"].as_str().unwrap_or("").to_string();
|
||||
if let Some(result_array) = json["result"].as_array() {
|
||||
for item in result_array.iter().take(MAX_EXPANDED_QUERIES) {
|
||||
// Take top 3 results
|
||||
let title = item["short_description"]
|
||||
.as_str()
|
||||
.unwrap_or("Untitled")
|
||||
.to_string();
|
||||
|
||||
let url = format!(
|
||||
"{}/kb_view.do?sysparm_article={}",
|
||||
instance_url.trim_end_matches('/'),
|
||||
sys_id
|
||||
);
|
||||
let sys_id = item["sys_id"].as_str().unwrap_or("").to_string();
|
||||
|
||||
let excerpt = item["text"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take(300)
|
||||
.collect::<String>();
|
||||
let url = format!(
|
||||
"{}/kb_view.do?sysparm_article={}",
|
||||
instance_url.trim_end_matches('/'),
|
||||
sys_id
|
||||
);
|
||||
|
||||
// Get full article content
|
||||
let content = item["text"].as_str().map(|text| {
|
||||
if text.len() > 3000 {
|
||||
format!("{}...", &text[..3000])
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
});
|
||||
let excerpt = item["text"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.take(300)
|
||||
.collect::<String>();
|
||||
|
||||
results.push(SearchResult {
|
||||
title,
|
||||
url,
|
||||
excerpt,
|
||||
content,
|
||||
source: "ServiceNow".to_string(),
|
||||
});
|
||||
// Get full article content
|
||||
let content = item["text"].as_str().map(|text| {
|
||||
if text.len() > 3000 {
|
||||
format!("{}...", &text[..3000])
|
||||
} else {
|
||||
text.to_string()
|
||||
}
|
||||
});
|
||||
|
||||
all_results.push(SearchResult {
|
||||
title,
|
||||
url,
|
||||
excerpt,
|
||||
content,
|
||||
source: "ServiceNow".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
all_results.sort_by(|a, b| a.url.cmp(&b.url));
|
||||
all_results.dedup_by(|a, b| a.url == b.url);
|
||||
|
||||
Ok(all_results)
|
||||
}
|
||||
|
||||
/// Search ServiceNow Incidents for related issues
|
||||
@ -96,68 +105,78 @@ pub async fn search_incidents(
|
||||
let cookie_header = crate::integrations::webview_auth::cookies_to_header(cookies);
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Search incidents
|
||||
let search_url = format!(
|
||||
"{}/api/now/table/incident?sysparm_query=short_descriptionLIKE{}^ORdescriptionLIKE{}&sysparm_limit=3&sysparm_display_value=true",
|
||||
instance_url.trim_end_matches('/'),
|
||||
urlencoding::encode(query),
|
||||
urlencoding::encode(query)
|
||||
);
|
||||
let expanded_queries = expand_query(query);
|
||||
|
||||
tracing::info!("Searching ServiceNow incidents: {}", search_url);
|
||||
let mut all_results = Vec::new();
|
||||
|
||||
let resp = client
|
||||
.get(&search_url)
|
||||
.header("Cookie", &cookie_header)
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("ServiceNow incident search failed: {e}"))?;
|
||||
for expanded_query in expanded_queries.iter().take(MAX_EXPANDED_QUERIES) {
|
||||
// Search incidents
|
||||
let search_url = format!(
|
||||
"{}/api/now/table/incident?sysparm_query=short_descriptionLIKE{}^ORdescriptionLIKE{}&sysparm_limit=3&sysparm_display_value=true",
|
||||
instance_url.trim_end_matches('/'),
|
||||
urlencoding::encode(expanded_query),
|
||||
urlencoding::encode(expanded_query)
|
||||
);
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Ok(Vec::new()); // Don't fail if incident search fails
|
||||
}
|
||||
tracing::info!(
|
||||
"Searching ServiceNow incidents with query: {}",
|
||||
expanded_query
|
||||
);
|
||||
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|_| "Failed to parse incident response".to_string())?;
|
||||
let resp = client
|
||||
.get(&search_url)
|
||||
.header("Cookie", &cookie_header)
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("ServiceNow incident search failed: {e}"))?;
|
||||
|
||||
let mut results = Vec::new();
|
||||
if !resp.status().is_success() {
|
||||
continue; // Don't fail if incident search fails
|
||||
}
|
||||
|
||||
if let Some(result_array) = json["result"].as_array() {
|
||||
for item in result_array.iter() {
|
||||
let number = item["number"].as_str().unwrap_or("Unknown");
|
||||
let title = format!(
|
||||
"Incident {}: {}",
|
||||
number,
|
||||
item["short_description"].as_str().unwrap_or("No title")
|
||||
);
|
||||
let json: serde_json::Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|_| "Failed to parse incident response".to_string())?;
|
||||
|
||||
let sys_id = item["sys_id"].as_str().unwrap_or("");
|
||||
let url = format!(
|
||||
"{}/incident.do?sys_id={}",
|
||||
instance_url.trim_end_matches('/'),
|
||||
sys_id
|
||||
);
|
||||
if let Some(result_array) = json["result"].as_array() {
|
||||
for item in result_array.iter() {
|
||||
let number = item["number"].as_str().unwrap_or("Unknown");
|
||||
let title = format!(
|
||||
"Incident {}: {}",
|
||||
number,
|
||||
item["short_description"].as_str().unwrap_or("No title")
|
||||
);
|
||||
|
||||
let description = item["description"].as_str().unwrap_or("").to_string();
|
||||
let sys_id = item["sys_id"].as_str().unwrap_or("");
|
||||
let url = format!(
|
||||
"{}/incident.do?sys_id={}",
|
||||
instance_url.trim_end_matches('/'),
|
||||
sys_id
|
||||
);
|
||||
|
||||
let resolution = item["close_notes"].as_str().unwrap_or("").to_string();
|
||||
let description = item["description"].as_str().unwrap_or("").to_string();
|
||||
|
||||
let content = format!("Description: {description}\nResolution: {resolution}");
|
||||
let resolution = item["close_notes"].as_str().unwrap_or("").to_string();
|
||||
|
||||
let excerpt = content.chars().take(200).collect::<String>();
|
||||
let content = format!("Description: {description}\nResolution: {resolution}");
|
||||
|
||||
results.push(SearchResult {
|
||||
title,
|
||||
url,
|
||||
excerpt,
|
||||
content: Some(content),
|
||||
source: "ServiceNow".to_string(),
|
||||
});
|
||||
let excerpt = content.chars().take(200).collect::<String>();
|
||||
|
||||
all_results.push(SearchResult {
|
||||
title,
|
||||
url,
|
||||
excerpt,
|
||||
content: Some(content),
|
||||
source: "ServiceNow".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
all_results.sort_by(|a, b| a.url.cmp(&b.url));
|
||||
all_results.dedup_by(|a, b| a.url == b.url);
|
||||
|
||||
Ok(all_results)
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ use serde_json::Value;
|
||||
use tauri::WebviewWindow;
|
||||
|
||||
use super::confluence_search::SearchResult;
|
||||
use crate::integrations::query_expansion::expand_query;
|
||||
|
||||
/// Execute an HTTP request from within the webview context
|
||||
/// This automatically includes all cookies (including HttpOnly) from the authenticated session
|
||||
@ -123,106 +124,113 @@ pub async fn search_confluence_webview<R: tauri::Runtime>(
|
||||
base_url: &str,
|
||||
query: &str,
|
||||
) -> Result<Vec<SearchResult>, String> {
|
||||
// Extract keywords from the query for better search
|
||||
// Remove common words and extract important terms
|
||||
let keywords = extract_keywords(query);
|
||||
let expanded_queries = expand_query(query);
|
||||
|
||||
// Build CQL query with OR logic for keywords
|
||||
let cql = if keywords.len() > 1 {
|
||||
// Multiple keywords - search for any of them
|
||||
let keyword_conditions: Vec<String> =
|
||||
keywords.iter().map(|k| format!("text ~ \"{k}\"")).collect();
|
||||
keyword_conditions.join(" OR ")
|
||||
} else if !keywords.is_empty() {
|
||||
// Single keyword
|
||||
let keyword = &keywords[0];
|
||||
format!("text ~ \"{keyword}\"")
|
||||
} else {
|
||||
// Fallback to original query
|
||||
format!("text ~ \"{query}\"")
|
||||
};
|
||||
let mut all_results = Vec::new();
|
||||
|
||||
let search_url = format!(
|
||||
"{}/rest/api/search?cql={}&limit=10",
|
||||
base_url.trim_end_matches('/'),
|
||||
urlencoding::encode(&cql)
|
||||
);
|
||||
for expanded_query in expanded_queries.iter().take(3) {
|
||||
// Extract keywords from the query for better search
|
||||
// Remove common words and extract important terms
|
||||
let keywords = extract_keywords(expanded_query);
|
||||
|
||||
tracing::info!("Executing Confluence search via webview with CQL: {}", cql);
|
||||
// Build CQL query with OR logic for keywords
|
||||
let cql = if keywords.len() > 1 {
|
||||
// Multiple keywords - search for any of them
|
||||
let keyword_conditions: Vec<String> =
|
||||
keywords.iter().map(|k| format!("text ~ \"{k}\"")).collect();
|
||||
keyword_conditions.join(" OR ")
|
||||
} else if !keywords.is_empty() {
|
||||
// Single keyword
|
||||
let keyword = &keywords[0];
|
||||
format!("text ~ \"{keyword}\"")
|
||||
} else {
|
||||
// Fallback to expanded query
|
||||
format!("text ~ \"{expanded_query}\"")
|
||||
};
|
||||
|
||||
let response = fetch_from_webview(webview_window, &search_url, "GET", None).await?;
|
||||
let search_url = format!(
|
||||
"{}/rest/api/search?cql={}&limit=10",
|
||||
base_url.trim_end_matches('/'),
|
||||
urlencoding::encode(&cql)
|
||||
);
|
||||
|
||||
let mut results = Vec::new();
|
||||
tracing::info!("Executing Confluence search via webview with CQL: {}", cql);
|
||||
|
||||
if let Some(results_array) = response.get("results").and_then(|v| v.as_array()) {
|
||||
for item in results_array.iter().take(5) {
|
||||
let title = item["title"].as_str().unwrap_or("Untitled").to_string();
|
||||
let content_id = item["content"]["id"].as_str();
|
||||
let space_key = item["content"]["space"]["key"].as_str();
|
||||
let response = fetch_from_webview(webview_window, &search_url, "GET", None).await?;
|
||||
|
||||
let url = if let (Some(id), Some(space)) = (content_id, space_key) {
|
||||
format!(
|
||||
"{}/display/{}/{}",
|
||||
base_url.trim_end_matches('/'),
|
||||
space,
|
||||
id
|
||||
)
|
||||
} else {
|
||||
base_url.to_string()
|
||||
};
|
||||
if let Some(results_array) = response.get("results").and_then(|v| v.as_array()) {
|
||||
for item in results_array.iter().take(5) {
|
||||
let title = item["title"].as_str().unwrap_or("Untitled").to_string();
|
||||
let content_id = item["content"]["id"].as_str();
|
||||
let space_key = item["content"]["space"]["key"].as_str();
|
||||
|
||||
let excerpt = item["excerpt"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.replace("<span class=\"highlight\">", "")
|
||||
.replace("</span>", "");
|
||||
let url = if let (Some(id), Some(space)) = (content_id, space_key) {
|
||||
format!(
|
||||
"{}/display/{}/{}",
|
||||
base_url.trim_end_matches('/'),
|
||||
space,
|
||||
id
|
||||
)
|
||||
} else {
|
||||
base_url.to_string()
|
||||
};
|
||||
|
||||
// Fetch full page content
|
||||
let content = if let Some(id) = content_id {
|
||||
let content_url = format!(
|
||||
"{}/rest/api/content/{id}?expand=body.storage",
|
||||
base_url.trim_end_matches('/')
|
||||
);
|
||||
if let Ok(content_resp) =
|
||||
fetch_from_webview(webview_window, &content_url, "GET", None).await
|
||||
{
|
||||
if let Some(body) = content_resp
|
||||
.get("body")
|
||||
.and_then(|b| b.get("storage"))
|
||||
.and_then(|s| s.get("value"))
|
||||
.and_then(|v| v.as_str())
|
||||
let excerpt = item["excerpt"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.replace("<span class=\"highlight\">", "")
|
||||
.replace("</span>", "");
|
||||
|
||||
// Fetch full page content
|
||||
let content = if let Some(id) = content_id {
|
||||
let content_url = format!(
|
||||
"{}/rest/api/content/{id}?expand=body.storage",
|
||||
base_url.trim_end_matches('/')
|
||||
);
|
||||
if let Ok(content_resp) =
|
||||
fetch_from_webview(webview_window, &content_url, "GET", None).await
|
||||
{
|
||||
let text = strip_html_simple(body);
|
||||
Some(if text.len() > 3000 {
|
||||
format!("{}...", &text[..3000])
|
||||
if let Some(body) = content_resp
|
||||
.get("body")
|
||||
.and_then(|b| b.get("storage"))
|
||||
.and_then(|s| s.get("value"))
|
||||
.and_then(|v| v.as_str())
|
||||
{
|
||||
let text = strip_html_simple(body);
|
||||
Some(if text.len() > 3000 {
|
||||
format!("{}...", &text[..3000])
|
||||
} else {
|
||||
text
|
||||
})
|
||||
} else {
|
||||
text
|
||||
})
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
};
|
||||
|
||||
results.push(SearchResult {
|
||||
title,
|
||||
url,
|
||||
excerpt: excerpt.chars().take(300).collect(),
|
||||
content,
|
||||
source: "Confluence".to_string(),
|
||||
});
|
||||
all_results.push(SearchResult {
|
||||
title,
|
||||
url,
|
||||
excerpt: excerpt.chars().take(300).collect(),
|
||||
content,
|
||||
source: "Confluence".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all_results.sort_by(|a, b| a.url.cmp(&b.url));
|
||||
all_results.dedup_by(|a, b| a.url == b.url);
|
||||
|
||||
tracing::info!(
|
||||
"Confluence webview search returned {} results",
|
||||
results.len()
|
||||
all_results.len()
|
||||
);
|
||||
Ok(results)
|
||||
Ok(all_results)
|
||||
}
|
||||
|
||||
/// Extract keywords from a search query
|
||||
@ -296,92 +304,99 @@ pub async fn search_servicenow_webview<R: tauri::Runtime>(
|
||||
instance_url: &str,
|
||||
query: &str,
|
||||
) -> Result<Vec<SearchResult>, String> {
|
||||
let mut results = Vec::new();
|
||||
let expanded_queries = expand_query(query);
|
||||
|
||||
// Search knowledge base
|
||||
let kb_url = format!(
|
||||
"{}/api/now/table/kb_knowledge?sysparm_query=textLIKE{}^ORshort_descriptionLIKE{}&sysparm_limit=3",
|
||||
instance_url.trim_end_matches('/'),
|
||||
urlencoding::encode(query),
|
||||
urlencoding::encode(query)
|
||||
);
|
||||
let mut all_results = Vec::new();
|
||||
|
||||
tracing::info!("Executing ServiceNow KB search via webview");
|
||||
for expanded_query in expanded_queries.iter().take(3) {
|
||||
// Search knowledge base
|
||||
let kb_url = format!(
|
||||
"{}/api/now/table/kb_knowledge?sysparm_query=textLIKE{}^ORshort_descriptionLIKE{}&sysparm_limit=3",
|
||||
instance_url.trim_end_matches('/'),
|
||||
urlencoding::encode(expanded_query),
|
||||
urlencoding::encode(expanded_query)
|
||||
);
|
||||
|
||||
if let Ok(kb_response) = fetch_from_webview(webview_window, &kb_url, "GET", None).await {
|
||||
if let Some(kb_array) = kb_response.get("result").and_then(|v| v.as_array()) {
|
||||
for item in kb_array {
|
||||
let title = item["short_description"]
|
||||
.as_str()
|
||||
.unwrap_or("Untitled")
|
||||
.to_string();
|
||||
let sys_id = item["sys_id"].as_str().unwrap_or("");
|
||||
let url = format!(
|
||||
"{}/kb_view.do?sysparm_article={sys_id}",
|
||||
instance_url.trim_end_matches('/')
|
||||
);
|
||||
let text = item["text"].as_str().unwrap_or("");
|
||||
let excerpt = text.chars().take(300).collect();
|
||||
let content = Some(if text.len() > 3000 {
|
||||
format!("{}...", &text[..3000])
|
||||
} else {
|
||||
text.to_string()
|
||||
});
|
||||
tracing::info!("Executing ServiceNow KB search via webview with expanded query");
|
||||
|
||||
results.push(SearchResult {
|
||||
title,
|
||||
url,
|
||||
excerpt,
|
||||
content,
|
||||
source: "ServiceNow".to_string(),
|
||||
});
|
||||
if let Ok(kb_response) = fetch_from_webview(webview_window, &kb_url, "GET", None).await {
|
||||
if let Some(kb_array) = kb_response.get("result").and_then(|v| v.as_array()) {
|
||||
for item in kb_array {
|
||||
let title = item["short_description"]
|
||||
.as_str()
|
||||
.unwrap_or("Untitled")
|
||||
.to_string();
|
||||
let sys_id = item["sys_id"].as_str().unwrap_or("");
|
||||
let url = format!(
|
||||
"{}/kb_view.do?sysparm_article={sys_id}",
|
||||
instance_url.trim_end_matches('/')
|
||||
);
|
||||
let text = item["text"].as_str().unwrap_or("");
|
||||
let excerpt = text.chars().take(300).collect();
|
||||
let content = Some(if text.len() > 3000 {
|
||||
format!("{}...", &text[..3000])
|
||||
} else {
|
||||
text.to_string()
|
||||
});
|
||||
|
||||
all_results.push(SearchResult {
|
||||
title,
|
||||
url,
|
||||
excerpt,
|
||||
content,
|
||||
source: "ServiceNow".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search incidents
|
||||
let inc_url = format!(
|
||||
"{}/api/now/table/incident?sysparm_query=short_descriptionLIKE{}^ORdescriptionLIKE{}&sysparm_limit=3&sysparm_display_value=true",
|
||||
instance_url.trim_end_matches('/'),
|
||||
urlencoding::encode(expanded_query),
|
||||
urlencoding::encode(expanded_query)
|
||||
);
|
||||
|
||||
if let Ok(inc_response) = fetch_from_webview(webview_window, &inc_url, "GET", None).await {
|
||||
if let Some(inc_array) = inc_response.get("result").and_then(|v| v.as_array()) {
|
||||
for item in inc_array {
|
||||
let number = item["number"].as_str().unwrap_or("Unknown");
|
||||
let title = format!(
|
||||
"Incident {}: {}",
|
||||
number,
|
||||
item["short_description"].as_str().unwrap_or("No title")
|
||||
);
|
||||
let sys_id = item["sys_id"].as_str().unwrap_or("");
|
||||
let url = format!(
|
||||
"{}/incident.do?sys_id={sys_id}",
|
||||
instance_url.trim_end_matches('/')
|
||||
);
|
||||
let description = item["description"].as_str().unwrap_or("");
|
||||
let resolution = item["close_notes"].as_str().unwrap_or("");
|
||||
let content = format!("Description: {description}\nResolution: {resolution}");
|
||||
let excerpt = content.chars().take(200).collect();
|
||||
|
||||
all_results.push(SearchResult {
|
||||
title,
|
||||
url,
|
||||
excerpt,
|
||||
content: Some(content),
|
||||
source: "ServiceNow".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search incidents
|
||||
let inc_url = format!(
|
||||
"{}/api/now/table/incident?sysparm_query=short_descriptionLIKE{}^ORdescriptionLIKE{}&sysparm_limit=3&sysparm_display_value=true",
|
||||
instance_url.trim_end_matches('/'),
|
||||
urlencoding::encode(query),
|
||||
urlencoding::encode(query)
|
||||
);
|
||||
|
||||
if let Ok(inc_response) = fetch_from_webview(webview_window, &inc_url, "GET", None).await {
|
||||
if let Some(inc_array) = inc_response.get("result").and_then(|v| v.as_array()) {
|
||||
for item in inc_array {
|
||||
let number = item["number"].as_str().unwrap_or("Unknown");
|
||||
let title = format!(
|
||||
"Incident {}: {}",
|
||||
number,
|
||||
item["short_description"].as_str().unwrap_or("No title")
|
||||
);
|
||||
let sys_id = item["sys_id"].as_str().unwrap_or("");
|
||||
let url = format!(
|
||||
"{}/incident.do?sys_id={sys_id}",
|
||||
instance_url.trim_end_matches('/')
|
||||
);
|
||||
let description = item["description"].as_str().unwrap_or("");
|
||||
let resolution = item["close_notes"].as_str().unwrap_or("");
|
||||
let content = format!("Description: {description}\nResolution: {resolution}");
|
||||
let excerpt = content.chars().take(200).collect();
|
||||
|
||||
results.push(SearchResult {
|
||||
title,
|
||||
url,
|
||||
excerpt,
|
||||
content: Some(content),
|
||||
source: "ServiceNow".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
all_results.sort_by(|a, b| a.url.cmp(&b.url));
|
||||
all_results.dedup_by(|a, b| a.url == b.url);
|
||||
|
||||
tracing::info!(
|
||||
"ServiceNow webview search returned {} results",
|
||||
results.len()
|
||||
all_results.len()
|
||||
);
|
||||
Ok(results)
|
||||
Ok(all_results)
|
||||
}
|
||||
|
||||
/// Search Azure DevOps wiki using webview fetch
|
||||
@ -391,82 +406,89 @@ pub async fn search_azuredevops_wiki_webview<R: tauri::Runtime>(
|
||||
project: &str,
|
||||
query: &str,
|
||||
) -> Result<Vec<SearchResult>, String> {
|
||||
// Extract keywords for better search
|
||||
let keywords = extract_keywords(query);
|
||||
let expanded_queries = expand_query(query);
|
||||
|
||||
let search_text = if !keywords.is_empty() {
|
||||
keywords.join(" ")
|
||||
} else {
|
||||
query.to_string()
|
||||
};
|
||||
let mut all_results = Vec::new();
|
||||
|
||||
// Azure DevOps wiki search API
|
||||
let search_url = format!(
|
||||
"{}/{}/_apis/wiki/wikis?api-version=7.0",
|
||||
org_url.trim_end_matches('/'),
|
||||
urlencoding::encode(project)
|
||||
);
|
||||
for expanded_query in expanded_queries.iter().take(3) {
|
||||
// Extract keywords for better search
|
||||
let keywords = extract_keywords(expanded_query);
|
||||
|
||||
tracing::info!(
|
||||
"Executing Azure DevOps wiki search via webview for: {}",
|
||||
search_text
|
||||
);
|
||||
let search_text = if !keywords.is_empty() {
|
||||
keywords.join(" ")
|
||||
} else {
|
||||
expanded_query.clone()
|
||||
};
|
||||
|
||||
// First, get list of wikis
|
||||
let wikis_response = fetch_from_webview(webview_window, &search_url, "GET", None).await?;
|
||||
// Azure DevOps wiki search API
|
||||
let search_url = format!(
|
||||
"{}/{}/_apis/wiki/wikis?api-version=7.0",
|
||||
org_url.trim_end_matches('/'),
|
||||
urlencoding::encode(project)
|
||||
);
|
||||
|
||||
let mut results = Vec::new();
|
||||
tracing::info!(
|
||||
"Executing Azure DevOps wiki search via webview for: {}",
|
||||
search_text
|
||||
);
|
||||
|
||||
if let Some(wikis_array) = wikis_response.get("value").and_then(|v| v.as_array()) {
|
||||
// Search each wiki
|
||||
for wiki in wikis_array.iter().take(3) {
|
||||
let wiki_id = wiki["id"].as_str().unwrap_or("");
|
||||
// First, get list of wikis
|
||||
let wikis_response = fetch_from_webview(webview_window, &search_url, "GET", None).await?;
|
||||
|
||||
if wiki_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(wikis_array) = wikis_response.get("value").and_then(|v| v.as_array()) {
|
||||
// Search each wiki
|
||||
for wiki in wikis_array.iter().take(3) {
|
||||
let wiki_id = wiki["id"].as_str().unwrap_or("");
|
||||
|
||||
// Search wiki pages
|
||||
let pages_url = format!(
|
||||
"{}/{}/_apis/wiki/wikis/{}/pages?recursionLevel=Full&includeContent=true&api-version=7.0",
|
||||
org_url.trim_end_matches('/'),
|
||||
urlencoding::encode(project),
|
||||
urlencoding::encode(wiki_id)
|
||||
);
|
||||
if wiki_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(pages_response) =
|
||||
fetch_from_webview(webview_window, &pages_url, "GET", None).await
|
||||
{
|
||||
// Try to get "page" field, or use the response itself if it's the page object
|
||||
if let Some(page) = pages_response.get("page") {
|
||||
search_page_recursive(
|
||||
page,
|
||||
&search_text,
|
||||
org_url,
|
||||
project,
|
||||
wiki_id,
|
||||
&mut results,
|
||||
);
|
||||
} else {
|
||||
// Response might be the page object itself
|
||||
search_page_recursive(
|
||||
&pages_response,
|
||||
&search_text,
|
||||
org_url,
|
||||
project,
|
||||
wiki_id,
|
||||
&mut results,
|
||||
);
|
||||
// Search wiki pages
|
||||
let pages_url = format!(
|
||||
"{}/{}/_apis/wiki/wikis/{}/pages?recursionLevel=Full&includeContent=true&api-version=7.0",
|
||||
org_url.trim_end_matches('/'),
|
||||
urlencoding::encode(project),
|
||||
urlencoding::encode(wiki_id)
|
||||
);
|
||||
|
||||
if let Ok(pages_response) =
|
||||
fetch_from_webview(webview_window, &pages_url, "GET", None).await
|
||||
{
|
||||
// Try to get "page" field, or use the response itself if it's the page object
|
||||
if let Some(page) = pages_response.get("page") {
|
||||
search_page_recursive(
|
||||
page,
|
||||
&search_text,
|
||||
org_url,
|
||||
project,
|
||||
wiki_id,
|
||||
&mut all_results,
|
||||
);
|
||||
} else {
|
||||
// Response might be the page object itself
|
||||
search_page_recursive(
|
||||
&pages_response,
|
||||
&search_text,
|
||||
org_url,
|
||||
project,
|
||||
wiki_id,
|
||||
&mut all_results,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all_results.sort_by(|a, b| a.url.cmp(&b.url));
|
||||
all_results.dedup_by(|a, b| a.url == b.url);
|
||||
|
||||
tracing::info!(
|
||||
"Azure DevOps wiki webview search returned {} results",
|
||||
results.len()
|
||||
all_results.len()
|
||||
);
|
||||
Ok(results)
|
||||
Ok(all_results)
|
||||
}
|
||||
|
||||
/// Recursively search through wiki pages for matching content
|
||||
@ -544,115 +566,124 @@ pub async fn search_azuredevops_workitems_webview<R: tauri::Runtime>(
|
||||
project: &str,
|
||||
query: &str,
|
||||
) -> Result<Vec<SearchResult>, String> {
|
||||
// Extract keywords
|
||||
let keywords = extract_keywords(query);
|
||||
let expanded_queries = expand_query(query);
|
||||
|
||||
// Check if query contains a work item ID (pure number)
|
||||
let work_item_id: Option<i64> = keywords
|
||||
.iter()
|
||||
.filter(|k| k.chars().all(|c| c.is_numeric()))
|
||||
.filter_map(|k| k.parse::<i64>().ok())
|
||||
.next();
|
||||
let mut all_results = Vec::new();
|
||||
|
||||
// Build WIQL query
|
||||
let wiql_query = if let Some(id) = work_item_id {
|
||||
// Search by specific ID
|
||||
format!(
|
||||
"SELECT [System.Id], [System.Title], [System.Description], [System.WorkItemType] \
|
||||
FROM WorkItems WHERE [System.Id] = {id}"
|
||||
)
|
||||
} else {
|
||||
// Search by text in title/description
|
||||
let search_terms = if !keywords.is_empty() {
|
||||
keywords.join(" ")
|
||||
for expanded_query in expanded_queries.iter().take(3) {
|
||||
// Extract keywords
|
||||
let keywords = extract_keywords(expanded_query);
|
||||
|
||||
// Check if query contains a work item ID (pure number)
|
||||
let work_item_id: Option<i64> = keywords
|
||||
.iter()
|
||||
.filter(|k| k.chars().all(|c| c.is_numeric()))
|
||||
.filter_map(|k| k.parse::<i64>().ok())
|
||||
.next();
|
||||
|
||||
// Build WIQL query
|
||||
let wiql_query = if let Some(id) = work_item_id {
|
||||
// Search by specific ID
|
||||
format!(
|
||||
"SELECT [System.Id], [System.Title], [System.Description], [System.WorkItemType] \
|
||||
FROM WorkItems WHERE [System.Id] = {id}"
|
||||
)
|
||||
} else {
|
||||
query.to_string()
|
||||
// Search by text in title/description
|
||||
let search_terms = if !keywords.is_empty() {
|
||||
keywords.join(" ")
|
||||
} else {
|
||||
expanded_query.clone()
|
||||
};
|
||||
|
||||
// Use CONTAINS for text search (case-insensitive)
|
||||
format!(
|
||||
"SELECT [System.Id], [System.Title], [System.Description], [System.WorkItemType] \
|
||||
FROM WorkItems WHERE [System.TeamProject] = '{project}' \
|
||||
AND ([System.Title] CONTAINS '{search_terms}' OR [System.Description] CONTAINS '{search_terms}') \
|
||||
ORDER BY [System.ChangedDate] DESC"
|
||||
)
|
||||
};
|
||||
|
||||
// Use CONTAINS for text search (case-insensitive)
|
||||
format!(
|
||||
"SELECT [System.Id], [System.Title], [System.Description], [System.WorkItemType] \
|
||||
FROM WorkItems WHERE [System.TeamProject] = '{project}' \
|
||||
AND ([System.Title] CONTAINS '{search_terms}' OR [System.Description] CONTAINS '{search_terms}') \
|
||||
ORDER BY [System.ChangedDate] DESC"
|
||||
)
|
||||
};
|
||||
let wiql_url = format!(
|
||||
"{}/{}/_apis/wit/wiql?api-version=7.0",
|
||||
org_url.trim_end_matches('/'),
|
||||
urlencoding::encode(project)
|
||||
);
|
||||
|
||||
let wiql_url = format!(
|
||||
"{}/{}/_apis/wit/wiql?api-version=7.0",
|
||||
org_url.trim_end_matches('/'),
|
||||
urlencoding::encode(project)
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"query": wiql_query
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let body = serde_json::json!({
|
||||
"query": wiql_query
|
||||
})
|
||||
.to_string();
|
||||
tracing::info!("Executing Azure DevOps work item search via webview");
|
||||
tracing::debug!("WIQL query: {}", wiql_query);
|
||||
tracing::debug!("Request URL: {}", wiql_url);
|
||||
|
||||
tracing::info!("Executing Azure DevOps work item search via webview");
|
||||
tracing::debug!("WIQL query: {}", wiql_query);
|
||||
tracing::debug!("Request URL: {}", wiql_url);
|
||||
let wiql_response =
|
||||
fetch_from_webview(webview_window, &wiql_url, "POST", Some(&body)).await?;
|
||||
|
||||
let wiql_response = fetch_from_webview(webview_window, &wiql_url, "POST", Some(&body)).await?;
|
||||
if let Some(work_items) = wiql_response.get("workItems").and_then(|v| v.as_array()) {
|
||||
// Fetch details for first 5 work items
|
||||
for item in work_items.iter().take(5) {
|
||||
if let Some(id) = item.get("id").and_then(|i| i.as_i64()) {
|
||||
let details_url = format!(
|
||||
"{}/_apis/wit/workitems/{}?api-version=7.0",
|
||||
org_url.trim_end_matches('/'),
|
||||
id
|
||||
);
|
||||
|
||||
let mut results = Vec::new();
|
||||
if let Ok(details) =
|
||||
fetch_from_webview(webview_window, &details_url, "GET", None).await
|
||||
{
|
||||
if let Some(fields) = details.get("fields") {
|
||||
let title = fields
|
||||
.get("System.Title")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("Untitled");
|
||||
let work_item_type = fields
|
||||
.get("System.WorkItemType")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("Item");
|
||||
let description = fields
|
||||
.get("System.Description")
|
||||
.and_then(|d| d.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if let Some(work_items) = wiql_response.get("workItems").and_then(|v| v.as_array()) {
|
||||
// Fetch details for first 5 work items
|
||||
for item in work_items.iter().take(5) {
|
||||
if let Some(id) = item.get("id").and_then(|i| i.as_i64()) {
|
||||
let details_url = format!(
|
||||
"{}/_apis/wit/workitems/{}?api-version=7.0",
|
||||
org_url.trim_end_matches('/'),
|
||||
id
|
||||
);
|
||||
let clean_description = strip_html_simple(description);
|
||||
let excerpt = clean_description.chars().take(200).collect();
|
||||
|
||||
if let Ok(details) =
|
||||
fetch_from_webview(webview_window, &details_url, "GET", None).await
|
||||
{
|
||||
if let Some(fields) = details.get("fields") {
|
||||
let title = fields
|
||||
.get("System.Title")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("Untitled");
|
||||
let work_item_type = fields
|
||||
.get("System.WorkItemType")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("Item");
|
||||
let description = fields
|
||||
.get("System.Description")
|
||||
.and_then(|d| d.as_str())
|
||||
.unwrap_or("");
|
||||
let url =
|
||||
format!("{}/_workitems/edit/{id}", org_url.trim_end_matches('/'));
|
||||
|
||||
let clean_description = strip_html_simple(description);
|
||||
let excerpt = clean_description.chars().take(200).collect();
|
||||
let full_content = if clean_description.len() > 3000 {
|
||||
format!("{}...", &clean_description[..3000])
|
||||
} else {
|
||||
clean_description.clone()
|
||||
};
|
||||
|
||||
let url = format!("{}/_workitems/edit/{id}", org_url.trim_end_matches('/'));
|
||||
|
||||
let full_content = if clean_description.len() > 3000 {
|
||||
format!("{}...", &clean_description[..3000])
|
||||
} else {
|
||||
clean_description.clone()
|
||||
};
|
||||
|
||||
results.push(SearchResult {
|
||||
title: format!("{work_item_type} #{id}: {title}"),
|
||||
url,
|
||||
excerpt,
|
||||
content: Some(full_content),
|
||||
source: "Azure DevOps".to_string(),
|
||||
});
|
||||
all_results.push(SearchResult {
|
||||
title: format!("{work_item_type} #{id}: {title}"),
|
||||
url,
|
||||
excerpt,
|
||||
content: Some(full_content),
|
||||
source: "Azure DevOps".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
all_results.sort_by(|a, b| a.url.cmp(&b.url));
|
||||
all_results.dedup_by(|a, b| a.url == b.url);
|
||||
|
||||
tracing::info!(
|
||||
"Azure DevOps work items webview search returned {} results",
|
||||
results.len()
|
||||
all_results.len()
|
||||
);
|
||||
Ok(results)
|
||||
Ok(all_results)
|
||||
}
|
||||
|
||||
/// Add a comment to an Azure DevOps work item
|
||||
|
||||
@ -120,6 +120,7 @@ pub fn run() {
|
||||
commands::system::get_settings,
|
||||
commands::system::update_settings,
|
||||
commands::system::get_audit_log,
|
||||
commands::system::get_app_version,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("Error running Troubleshooting and RCA Assistant application");
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
"frontendDist": "../dist",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"beforeBuildCommand": "npm run build"
|
||||
"beforeBuildCommand": "npm run version:update && npm run build"
|
||||
},
|
||||
"app": {
|
||||
"security": {
|
||||
@ -26,7 +26,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"targets": ["deb", "rpm", "nsis"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
@ -41,4 +41,7 @@
|
||||
"shortDescription": "Troubleshooting and RCA Assistant",
|
||||
"longDescription": "Structured AI-backed assistant for IT troubleshooting, 5-whys root cause analysis, and post-mortem documentation with offline Ollama support."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { Routes, Route, NavLink, useLocation } from "react-router-dom";
|
||||
import {
|
||||
Home,
|
||||
@ -15,7 +14,7 @@ import {
|
||||
Moon,
|
||||
} from "lucide-react";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { loadAiProvidersCmd, testProviderConnectionCmd } from "@/lib/tauriCommands";
|
||||
import { getAppVersionCmd, loadAiProvidersCmd, testProviderConnectionCmd } from "@/lib/tauriCommands";
|
||||
|
||||
import Dashboard from "@/pages/Dashboard";
|
||||
import NewIssue from "@/pages/NewIssue";
|
||||
@ -50,7 +49,7 @@ export default function App() {
|
||||
void useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
getVersion().then(setAppVersion).catch(() => {});
|
||||
getAppVersionCmd().then(setAppVersion).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Load providers and auto-test active provider on startup
|
||||
|
||||
@ -486,3 +486,8 @@ export const loadAiProvidersCmd = () =>
|
||||
|
||||
export const deleteAiProviderCmd = (name: string) =>
|
||||
invoke<void>("delete_ai_provider", { name });
|
||||
|
||||
// ─── System / Version ─────────────────────────────────────────────────────────
|
||||
|
||||
export const getAppVersionCmd = () =>
|
||||
invoke<string>("get_app_version");
|
||||
|
||||
74
ticket-git-cliff-changelog.md
Normal file
74
ticket-git-cliff-changelog.md
Normal file
@ -0,0 +1,74 @@
|
||||
# feat: Automated Changelog via git-cliff
|
||||
|
||||
## Description
|
||||
|
||||
Introduces automated changelog generation using **git-cliff**, a tool that parses
|
||||
conventional commits and produces formatted Markdown changelogs.
|
||||
|
||||
Previously, every Gitea release body contained only the static text `"Release vX.Y.Z"`.
|
||||
With this change, releases display a categorised, human-readable list of all commits
|
||||
since the previous version.
|
||||
|
||||
**Root cause / motivation:** No changelog tooling existed. The project follows
|
||||
Conventional Commits throughout but the information was never surfaced to end-users.
|
||||
|
||||
**Files changed:**
|
||||
- `cliff.toml` (new) — git-cliff configuration; defines commit parsers, ignored tags,
|
||||
output template, and which commit types appear in the changelog
|
||||
- `CHANGELOG.md` (new) — bootstrapped from all existing tags; maintained by CI going forward
|
||||
- `.gitea/workflows/auto-tag.yml` — new `changelog` job that runs after `autotag`
|
||||
- `docs/wiki/CICD-Pipeline.md` — "Changelog Generation" section added
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `cliff.toml` present at repo root with working Tera template
|
||||
- [ ] `CHANGELOG.md` present at repo root, bootstrapped from all existing semver tags
|
||||
- [ ] `changelog` job in `auto-tag.yml` runs after `autotag` (parallel with build jobs)
|
||||
- [ ] Each Gitea release body shows grouped conventional-commit entries instead of
|
||||
static `"Release vX.Y.Z"`
|
||||
- [ ] `CHANGELOG.md` committed to master on every release with `[skip ci]` suffix
|
||||
(no infinite re-trigger loop)
|
||||
- [ ] `CHANGELOG.md` uploaded as a downloadable release asset
|
||||
- [ ] CI/chore/build/test/style commits excluded from changelog output
|
||||
- [ ] `docs/wiki/CICD-Pipeline.md` documents the changelog generation process
|
||||
|
||||
## Work Implemented
|
||||
|
||||
### `cliff.toml`
|
||||
- Tera template with proper whitespace control (`-%}` / `{%- `) for clean output
|
||||
- Included commit types: `feat`, `fix`, `perf`, `docs`, `refactor`
|
||||
- Excluded commit types: `ci`, `chore`, `build`, `test`, `style`
|
||||
- `ignore_tags = "rc|alpha|beta"` — pre-release tags excluded from version boundaries
|
||||
- `filter_unconventional = true` — non-conventional commits dropped silently
|
||||
- `sort_commits = "oldest"` — chronological order within each version
|
||||
|
||||
### `CHANGELOG.md`
|
||||
- Bootstrapped locally using git-cliff v2.7.0 (aarch64 musl binary)
|
||||
- Covers all tagged versions from `v0.1.0` through `v0.2.49` plus `[Unreleased]`
|
||||
- 267 lines covering the full project history
|
||||
|
||||
### `.gitea/workflows/auto-tag.yml` — `changelog` job
|
||||
- `needs: autotag` — waits for the new tag to exist before running
|
||||
- Full history clone: `git fetch --tags --depth=2147483647` so git-cliff can resolve
|
||||
all version boundaries
|
||||
- git-cliff v2.7.0 downloaded as a static x86_64 musl binary (~5 MB); no custom
|
||||
image required
|
||||
- Generates full `CHANGELOG.md` and per-release notes (`--latest --strip all`)
|
||||
- PATCHes the Gitea release body via API with JSON-safe escaping (`jq -Rs .`)
|
||||
- Commits `CHANGELOG.md` to master with `[skip ci]` to prevent workflow re-trigger
|
||||
- Deletes any existing `CHANGELOG.md` asset before re-uploading (rerun-safe)
|
||||
- Runs in parallel with all build jobs — no added wall-clock latency
|
||||
|
||||
### `docs/wiki/CICD-Pipeline.md`
|
||||
- Added "Changelog Generation" section before "Known Issues & Fixes"
|
||||
- Describes the five-step process, cliff.toml settings, and loop prevention mechanism
|
||||
|
||||
## Testing Needed
|
||||
|
||||
- [ ] Merge this PR to master; verify `changelog` CI job succeeds in Gitea Actions
|
||||
- [ ] Check Gitea release body for the new version tag — should show grouped commit list
|
||||
- [ ] Verify `CHANGELOG.md` was committed to master (check git log after CI runs)
|
||||
- [ ] Verify `CHANGELOG.md` appears as a downloadable asset on the release page
|
||||
- [ ] Push a subsequent commit to master; confirm the `[skip ci]` CHANGELOG commit does
|
||||
NOT trigger a second run of `auto-tag.yml`
|
||||
- [ ] Confirm CI/chore commits are absent from the release body
|
||||
Loading…
Reference in New Issue
Block a user