Compare commits

..
Author SHA1 Message Date
jared 4885848375 Add MIT license
Lint / PHP (phpcs PSR-12) (push) Successful in 20s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m20s
Lint / Deploy (push) Successful in 2s
2026-10-03 00:16:58 -04:00
jared cc160fe8ce Merge development: MCP ticket links + duplicate finder fixes (#113)
Lint / PHP (phpcs PSR-12) (push) Successful in 52s
Lint / JS (eslint) (push) Successful in 20s
Lint / PHP requirements (version + extensions) (push) Successful in 1m0s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m13s
Lint / Deploy (push) Successful in 4s
2026-09-26 17:13:41 -04:00
jaredandClaude Opus 5.5 c490d4f387 MCP: ticket links and similar-ticket search (#113)
Lint / PHP (phpcs PSR-12) (push) Successful in 48s
Lint / JS (eslint) (push) Successful in 17s
Lint / PHP requirements (version + extensions) (push) Successful in 49s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 3m15s
Lint / Deploy (push) Successful in 4s
- get_ticket now returns `links` (blocks / blocked_by / relates_to /
  duplicates / duplicated_by, phrased from this ticket's side, limited to
  linked tickets the user can see) and `blocked` (any open blocked_by).
- find_similar_tickets (tickets:read): the possible-duplicates finder, by
  title or by an existing ticket (which is excluded from the results).
- link_tickets / unlink_tickets (tickets:write). Marking a duplicate only
  records the link. unlink also finds a link stored from the other side
  ("B blocked_by A" for "A blocks B").

api/ticket_dependencies.php's list/add/remove logic moves to
services/DependencyService.php, used by both (same checks and messages).
DependencyModel's remove methods now return rows removed, so removing a
link that is already gone no longer writes a "deleted" audit row.

Also includes the port in ToolScopeMiddleware's resource_metadata URL
(matches the 401's; no effect on prod, which has no port).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-26 17:08:04 -04:00
jaredandClaude Opus 5.5 a9bc72fdcc Fix the possible-duplicates finder; share it as SimilarTicketService (#113)
check_duplicates.php's logic moves to services/SimilarTicketService.php
(used next by the MCP find_similar_tickets tool). Fixes found while
testing it:

- Non-admins never got matches: the query had no `t` alias but the
  visibility filter's SQL uses t.*, so it failed and the error was
  swallowed into "no duplicates".
- The word-overlap scoring could never fire, since candidates were only
  whole-title substring or SOUNDEX matches. Tickets sharing a significant
  word (4+ letters) are now candidates too; overlap needs 2+ shared words
  so one common word (e.g. every automated ticket's [problem] tag) isn't
  a match.
- SOUNDEX compared PHP's 4-char code (effectively the first word) with
  MariaDB's full-length code: the SQL side almost never matched, and the
  scorer marked any two titles sharing a first word as "sounds alike".
  Both sides now use SQL SOUNDEX on the full title.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-26 17:08:04 -04:00
jared 5b43e7d18f Merge development: per-warning Ceph dedup (#112)
Lint / PHP (phpcs PSR-12) (push) Successful in 18s
Lint / JS (eslint) (push) Successful in 7s
Lint / PHP requirements (version + extensions) (push) Successful in 21s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m46s
Lint / Deploy (push) Successful in 3s
2026-09-25 16:39:35 -04:00
jaredandClaude Opus 5.5 72feafb359 Give each Ceph HEALTH_WARN its own dedup ticket (#112)
Lint / PHP (phpcs PSR-12) (push) Successful in 51s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 39s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m36s
Lint / Deploy (push) Successful in 3s
Every HEALTH_WARN other than four known types hashed with an empty
subtype, so unrelated cluster warnings (noout, nearfull, mon space,
insecure keys, MDS standby, ...) all collapsed onto one ticket. Each run
the reported warnings overwrote its title in turn, writing ~47k
title-flip audit rows on ticket 221040637.

Unknown warnings now key on Ceph's health-check code when the reporter
sends one (optional `check_code` payload field, e.g. OSDMAP_FLAGS), else
on the warning text before its first colon with counts stripped, so
"3 pool(s) nearfull" and "4 pool(s) nearfull" stay one ticket. Known
subtypes hash exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-25 15:59:37 -04:00
jared da916bc5a6 Merge development: fix ticket page 500 (#112)
Lint / PHP (phpcs PSR-12) (push) Successful in 33s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 24s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m38s
Lint / Deploy (push) Successful in 5s
2026-09-25 15:45:07 -04:00
jaredandClaude Opus 5.5 ad201691e4 Fix ticket page 500 from unbounded audit timeline (#112)
Lint / PHP (phpcs PSR-12) (push) Successful in 21s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 20s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m22s
Lint / Deploy (push) Successful in 4s
A ticket updated by hwmonDaemon had ~52k audit rows; getTicketTimeline()
loaded all of them and exhausted PHP's 128MB memory limit, so the ticket
page returned 500. Not related to the MCP server.

- getTicketTimeline() takes a limit (newest first). The ticket page shows
  the latest 500 events with a note when older ones are omitted; the JSON
  export caps at 5000.
- create_ticket_api.php: the description is refreshed on every run, and
  that alone wrote a reason-only audit row every few minutes per open
  ticket. Audit only real title/priority changes.
- create_ticket_api.php: after creating a brand-new ticket the dedup retry
  loop fell through into a second iteration on a closed connection,
  appending a 500 error body after the success response. Exit instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-25 15:34:44 -04:00
jaredandClaude Opus 5.5 dad066cb32 Merge development into main: MCP server documentation (#111)
Lint / PHP (phpcs PSR-12) (push) Successful in 21s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 49s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m55s
Lint / Deploy (push) Successful in 2s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:26:42 -04:00
jaredandClaude Opus 5.5 9c633aa09b Document the MCP server: connecting Claude Code, tools, architecture (#111)
Lint / PHP (phpcs PSR-12) (push) Successful in 42s
Lint / JS (eslint) (push) Successful in 16s
Lint / PHP requirements (version + extensions) (push) Successful in 42s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m46s
Lint / Deploy (push) Successful in 2s
Adds a README section on connecting Claude Code to /mcp (the pre-registered
client id + fixed callback port Authelia requires, `claude mcp login`
incl. --no-browser for headless hosts), the six tools and their scopes,
and how the pieces fit together: Authelia as the authorization server
(and the 4.39.21/22 RFC 8707 bug to avoid), token validation incl. the
per-server audience, Composer being MCP-only, and the proxy exemptions.
Also updates the project-structure tree (mcp/, services/,
ApiTicketController, composer files, migrations 005-007) and notes that
uploads/ is only served via PHP.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X
2026-09-24 19:26:40 -04:00
16 changed files with 688 additions and 249 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2026 Jared Vititoe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+48 -1
View File
@@ -113,6 +113,36 @@ All Bearer-authenticated, rate-limited, and (like `create_ticket_api.php`) exemp
| `/api/ticket_comment_api.php` | POST | read_write | Add a comment: `{ticket_id, comment_text, markdown_enabled?}` |
| `/api/ticket_status_api.php` | POST | read_write | Change/close status (workflow-validated): `{ticket_id, status, comment?}` — `comment` is required for transitions that require one (e.g. → Closed); it is posted as the close reason in the same call |
### MCP server (Claude Code and other MCP clients)
`/mcp` is a remote [MCP](https://modelcontextprotocol.io) server (Streamable HTTP) protected by **OAuth via Authelia** — no API keys. You sign in once in the browser and every action runs **as you**: the same visibility, permissions, Workflow Designer rules, audit log and notifications as the web UI.
**Connect Claude Code** (one-time):
```bash
claude mcp add --transport http --scope user \
--client-id tinker-tickets-mcp --callback-port 47823 \
tinker https://t.lotusguild.org/mcp
claude mcp login tinker # add --no-browser on a headless machine, then paste the redirect URL back
```
`--client-id` / `--callback-port 47823` are required: Authelia doesn't support dynamic client registration, so Claude Code uses the pre-registered public client `tinker-tickets-mcp`, whose only allowed redirect is `http://localhost:47823/callback`. Access requires the same `admin`/`employee` group as the web UI. Tokens refresh automatically (refresh token lasts 30 days); `claude mcp logout tinker` signs out.
| Tool | Scope | What it does |
|------|-------|--------------|
| `search_tickets` | `tickets:read` | Search/list tickets you can see (text, status, priority, category, assignee `me`/`unassigned`/username), paginated |
| `get_ticket` | `tickets:read` | One ticket's details + comments, its links (Dependencies tab, phrased from this ticket's side incl. `duplicated_by`) and a `blocked` flag (any open `blocked_by`) |
| `find_similar_tickets` | `tickets:read` | Open tickets with similar titles, by title text or an existing ticket (same finder as the ticket page's possible-duplicates list) |
| `create_ticket` | `tickets:write` | Create a ticket (title, description, priority, category, type, visibility, assignee) |
| `add_comment` | `tickets:write` | Comment (markdown, @mentions, replies) |
| `update_status` | `tickets:write` | Workflow-validated status change; `comment` required when the transition requires one |
| `assign_ticket` | `tickets:write` | Assign / unassign (admin, creator, or current assignee only) |
| `link_tickets` | `tickets:write` | Add a `blocks` / `blocked_by` / `relates_to` / `duplicates` link ("A duplicates B" = A is the duplicate; links only, never closes anything) |
| `unlink_tickets` | `tickets:write` | Remove a link, whichever ticket's side it was recorded from |
How it fits together (see issue #111 for the full design):
- **Authelia** is the authorization server: client `tinker-tickets-mcp`, custom scopes `tickets:read`/`tickets:write`, RS256 JWT access tokens carrying `preferred_username`/`groups`. Needs Authelia **≠ 4.39.21/4.39.22** (RFC 8707 `resource` bug); 4.39.20 or ≥ 4.39.23 are fine.
- **`mcp/server.php`** is the resource server (official `mcp/sdk`, pinned). It validates each token's signature (JWKS), issuer, **audience = this server's URL** (`MCP_RESOURCE_URL`, derived from `APP_DOMAIN`, so a beta token can't be used on prod) and expiry, then maps the user with the same rules as the web login. It serves RFC 9728 metadata at `/.well-known/oauth-protected-resource/mcp`.
- **Composer is used only by the MCP endpoint** (`vendor/` is gitignored and installed by the deploy script with `composer install --no-dev`); nothing else in the app loads it.
- **Reverse proxy:** `/mcp` and `/.well-known/oauth-protected-resource` are exempt from Authelia forward-auth (the token is the credential) and have `Remote-*` headers blanked; `vendor/`, `mcp/` and `composer.*` are never served.
### User Management & Authentication
- **SSO Integration**: Authelia authentication with LLDAP backend
- **Role-Based Access**: Admin and standard user roles
@@ -355,7 +385,9 @@ tinker_tickets/
│ ├── config.php # Config + .env loading
│ └── requirements.php # PHP version/extension requirements (single source of
│ # truth for scripts/check_requirements.php + api/health.php)
├── composer.json / composer.lock # MCP endpoint dependencies only (mcp/sdk pinned); vendor/ installed at deploy
├── controllers/
│ ├── ApiTicketController.php # Partial ticket updates (status/fields/visibility), shared by update_ticket.php + MCP
│ ├── CommentController.php # Comment create/edit/delete + notifications
│ ├── DashboardController.php # Dashboard with stats + filters
│ └── TicketController.php # Ticket CRUD + timeline + visibility
@@ -399,11 +431,26 @@ tinker_tickets/
│ ├── 002_fix_collation_consistency.sql # Upgrade-only (already in baseline for fresh installs)
│ ├── 003_fk_on_delete_set_null.sql # Upgrade-only (already in baseline for fresh installs)
│ ├── 004_fix_ticket_watchers_type.sql # Upgrade-only (already in baseline for fresh installs)
│ ├── 005_attachment_thumbnails.sql # ticket_attachments.thumbnail_filename
│ ├── 006_api_key_visibility_scope.sql # api_keys.see_all_visibility (public-only by default)
│ ├── 007_notification_retry_queue.sql # Queue for failed Matrix webhook retries
│ └── migrate.php # CLI migration runner (tracks applied migrations)
├── mcp/
│ ├── server.php # MCP endpoint (/mcp): OAuth resource server; only file that loads vendor/
│ └── src/
│ ├── Auth/ # IdentityMiddleware (token -> user), ToolScopeMiddleware, McpIdentity
│ ├── Tools/ # TicketReadTools, TicketWriteTools
│ └── ToolCatalog.php # Tool registry + which tools need tickets:write
├── scripts/
│ ├── check_requirements.php # Verify PHP extensions/config prerequisites
│ └── cleanup_orphan_uploads.php # Delete orphaned upload files past grace period (cron)
├── uploads/ # File attachment storage
├── services/
│ ├── AssignmentService.php # Assign/unassign (shared by assign_ticket.php + MCP)
│ ├── CommentService.php # Add comment + mentions/notifications (shared by add_comment.php + MCP)
│ ├── DependencyService.php # List/add/remove ticket links (shared by ticket_dependencies.php + MCP)
│ ├── SimilarTicketService.php # Similar-title finder (shared by check_duplicates.php + MCP)
│ └── TicketCreationService.php # Create ticket (shared by TicketController + MCP)
├── uploads/ # File attachment storage (served only via PHP; nginx: internal)
│ └── avatars/ # lldap avatar disk cache
├── views/
│ ├── admin/
+2 -98
View File
@@ -8,7 +8,7 @@
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/services/SimilarTicketService.php';
// Only accept GET requests
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
@@ -18,100 +18,4 @@ if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
// Get title parameter
$title = isset($_GET['title']) ? trim($_GET['title']) : '';
if (strlen($title) < 5) {
ResponseHelper::success(['duplicates' => []]);
}
// Search for similar titles
// Use both LIKE for substring matching and SOUNDEX for phonetic matching
$duplicates = [];
// Prepare search term for LIKE
$searchTerm = '%' . $title . '%';
// Get SOUNDEX of title
$soundexTitle = soundex($title);
// Build visibility filter so users only see titles they have access to
$ticketModel = new TicketModel($conn);
$visFilter = $ticketModel->getVisibilityFilter($currentUser);
// First, search for exact substring matches (case-insensitive)
$sql = "SELECT ticket_id, title, status, priority, created_at
FROM tickets
WHERE (
title LIKE ?
OR SOUNDEX(title) = ?
)
AND status != 'Closed'
AND ({$visFilter['sql']})
ORDER BY created_at DESC
LIMIT 10";
$types = "ss" . $visFilter['types'];
$params = array_merge([$searchTerm, $soundexTitle], $visFilter['params']);
// Duplicate detection is advisory (it must not block ticket creation), so on any
// DB error degrade gracefully to "no duplicates" rather than fataling the request.
// mysqli may throw (default exception mode) or return false depending on config.
try {
$stmt = $conn->prepare($sql);
if (!$stmt) {
throw new RuntimeException('prepare failed: ' . $conn->error);
}
if (!empty($params)) {
$stmt->bind_param($types, ...$params);
}
$stmt->execute();
$result = $stmt->get_result();
if ($result === false) {
// Non-exception mysqli mode: execute/get_result return false instead of
// throwing. Treat as a query failure so we don't fatal on $result below.
throw new RuntimeException('query failed: ' . $conn->error);
}
} catch (Throwable $e) {
error_log('check_duplicates: ' . $e->getMessage());
ResponseHelper::success(['duplicates' => []]);
}
while ($row = $result->fetch_assoc()) {
// Calculate similarity score
$similarity = 0;
// Check for exact substring match
if (stripos($row['title'], $title) !== false) {
$similarity = 90;
// Check SOUNDEX match
} elseif (soundex($row['title']) === $soundexTitle) {
$similarity = 70;
// Check word overlap
} else {
$titleWords = array_map('strtolower', preg_split('/\s+/', $title));
$rowWords = array_map('strtolower', preg_split('/\s+/', $row['title']));
$matchingWords = array_intersect($titleWords, $rowWords);
$similarity = (count($matchingWords) / max(count($titleWords), 1)) * 60;
}
if ($similarity >= 30) {
$duplicates[] = [
'ticket_id' => $row['ticket_id'],
'title' => $row['title'],
'status' => $row['status'],
'priority' => $row['priority'],
'created_at' => $row['created_at'],
'similarity' => round($similarity)
];
}
}
$stmt->close();
// Sort by similarity descending
usort($duplicates, function ($a, $b) {
return $b['similarity'] - $a['similarity'];
});
// Limit to top 5
$duplicates = array_slice($duplicates, 0, 5);
ResponseHelper::success(['duplicates' => $duplicates]);
ResponseHelper::success(['duplicates' => SimilarTicketService::find($conn, $currentUser, $title)]);
+1 -1
View File
@@ -192,7 +192,7 @@ try {
// Load flat comment list (no threading nesting in export)
$rawComments = $commentModel->getCommentsByTicketId($ticket['ticket_id'], false);
$timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id']);
$timeline = $auditLogModel->getTicketTimeline((string)$ticket['ticket_id'], 5000);
$comments = array_map(function ($c) {
return [
+15 -131
View File
@@ -75,9 +75,7 @@ if (session_status() === PHP_SESSION_NONE) {
require_once dirname(__DIR__) . '/config/config.php';
require_once dirname(__DIR__) . '/helpers/Database.php';
require_once dirname(__DIR__) . '/models/DependencyModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
require_once dirname(__DIR__) . '/services/DependencyService.php';
require_once dirname(__DIR__) . '/helpers/ResponseHelper.php';
header('Content-Type: application/json');
@@ -87,11 +85,7 @@ if (!isset($_SESSION['user']) || !isset($_SESSION['user']['user_id'])) {
ResponseHelper::unauthorized();
}
$userId = $_SESSION['user']['user_id'];
$currentUser = $_SESSION['user'];
$isAdmin = $currentUser['is_admin'] ?? false;
// users.groups is a comma-separated string; the dependency model expects an array.
$userGroups = array_values(array_filter(array_map('trim', explode(',', $currentUser['groups'] ?? ''))));
// CSRF Protection for POST/DELETE
if ($_SERVER['REQUEST_METHOD'] === 'POST' || $_SERVER['REQUEST_METHOD'] === 'DELETE') {
@@ -111,158 +105,48 @@ if ($tableCheck->num_rows === 0) {
ResponseHelper::serverError('Ticket dependencies feature not available. The ticket_dependencies table does not exist. Please run the migration.');
}
try {
$dependencyModel = new DependencyModel($conn);
$auditLog = new AuditLogModel($conn);
$ticketModel = new TicketModel($conn);
} catch (Exception $e) {
error_log('Failed to initialize models in ticket_dependencies.php: ' . $e->getMessage());
ResponseHelper::serverError('Failed to initialize required components');
}
$method = $_SERVER['REQUEST_METHOD'];
try {
switch ($method) {
case 'GET':
// Get dependencies for a ticket
$ticketId = $_GET['ticket_id'] ?? null;
if (!$ticketId) {
ResponseHelper::error('Ticket ID required');
$result = DependencyService::list($conn, $currentUser, $_GET['ticket_id'] ?? null);
if (!$result['success']) {
ResponseHelper::error($result['error'], $result['http_status']);
}
// Verify user can access this ticket
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
try {
$dependencies = $dependencyModel->getDependencies($ticketId, $userId, $userGroups, $isAdmin);
$dependents = $dependencyModel->getDependentTickets($ticketId, $userId, $userGroups, $isAdmin);
} catch (Exception $e) {
error_log('Query error in ticket_dependencies.php GET: ' . $e->getMessage());
ResponseHelper::serverError('Failed to retrieve dependencies');
}
ResponseHelper::success([
'dependencies' => $dependencies,
'dependents' => $dependents
'dependencies' => $result['dependencies'],
'dependents' => $result['dependents']
]);
break;
case 'POST':
// Add a new dependency
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
ResponseHelper::error('Invalid JSON');
}
$ticketId = $data['ticket_id'] ?? null;
$dependsOnId = $data['depends_on_id'] ?? null;
$type = $data['dependency_type'] ?? 'blocks';
if (!$ticketId || !$dependsOnId) {
ResponseHelper::error('Both ticket_id and depends_on_id are required');
}
// Verify user can access both tickets before creating dependency
$srcTicket = $ticketModel->getTicketById($ticketId);
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
$tgtTicket = $ticketModel->getTicketById($dependsOnId);
if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) {
ResponseHelper::notFound('Target ticket not found');
}
$result = $dependencyModel->addDependency($ticketId, $dependsOnId, $type, $userId);
if ($result['success']) {
// Log to audit
$auditLog->log($userId, 'create', 'dependency', (string)$result['dependency_id'], [
'ticket_id' => $ticketId,
'depends_on_id' => $dependsOnId,
'type' => $type
]);
ResponseHelper::created($result);
} else {
ResponseHelper::error($result['error']);
$result = DependencyService::add($conn, $currentUser, $data);
if (!$result['success']) {
ResponseHelper::error($result['error'], $result['http_status']);
}
ResponseHelper::created($result);
break;
case 'DELETE':
// Remove a dependency
// Remove a dependency, by dependency_id or by ticket IDs + type
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
ResponseHelper::error('Invalid JSON');
}
$dependencyId = $data['dependency_id'] ?? null;
// Alternative: delete by ticket IDs
if (!$dependencyId && isset($data['ticket_id']) && isset($data['depends_on_id'])) {
$ticketId = $data['ticket_id'];
$dependsOnId = $data['depends_on_id'];
$type = $data['dependency_type'] ?? 'blocks';
// Validate dependency type
$validTypes = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
if (!in_array($type, $validTypes, true)) {
ResponseHelper::error('Invalid dependency type');
}
// Verify user can access the source ticket
$srcTicket = $ticketModel->getTicketById($ticketId);
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
ResponseHelper::notFound('Ticket not found');
}
$result = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
if ($result) {
$auditLog->log($userId, 'delete', 'dependency', null, [
'ticket_id' => $ticketId,
'depends_on_id' => $dependsOnId,
'type' => $type
]);
ResponseHelper::success([], 'Dependency removed');
} else {
ResponseHelper::error('Failed to remove dependency');
}
} elseif ($dependencyId) {
// Look up dependency to verify ticket access before deletion
$depLookupSql = "SELECT ticket_id FROM ticket_dependencies WHERE dependency_id = ?";
$depLookupStmt = $conn->prepare($depLookupSql);
$depLookupStmt->bind_param("i", $dependencyId);
$depLookupStmt->execute();
$depRow = $depLookupStmt->get_result()->fetch_assoc();
$depLookupStmt->close();
if (!$depRow) {
ResponseHelper::notFound('Dependency not found');
}
$depTicket = $ticketModel->getTicketById($depRow['ticket_id']);
if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) {
ResponseHelper::forbidden('Access denied');
}
$result = $dependencyModel->removeDependency($dependencyId);
if ($result) {
$auditLog->log($userId, 'delete', 'dependency', (string)$dependencyId);
ResponseHelper::success([], 'Dependency removed');
} else {
ResponseHelper::error('Failed to remove dependency');
}
} else {
ResponseHelper::error('Dependency ID or ticket IDs required');
$result = DependencyService::remove($conn, $currentUser, $data);
if (!$result['success']) {
ResponseHelper::error($result['error'], $result['http_status']);
}
ResponseHelper::success([], 'Dependency removed');
break;
default:
+8 -2
View File
@@ -54,8 +54,14 @@ class TicketController
$totalComments = $this->commentModel->getCommentCount((int)$id);
$comments = $this->commentModel->getCommentsByTicketId($id, true, $commentPageSize, 0);
// Get timeline for this ticket
$timeline = $this->auditLogModel->getTicketTimeline($id);
// Get the newest timeline events for this ticket. One extra row is
// fetched only to tell the view that older events were left out.
$timelineLimit = 500;
$timeline = $this->auditLogModel->getTicketTimeline($id, $timelineLimit + 1);
$timelineTruncated = count($timeline) > $timelineLimit;
if ($timelineTruncated) {
array_pop($timeline);
}
// Get all users for assignment dropdown
$allUsers = $this->userModel->getAllUsers();
+32 -4
View File
@@ -159,6 +159,23 @@ function generateTicketHash($data)
}
} elseif (stripos($title, 'HEALTH_ERR') !== false) {
$issueSubtype = 'health_err';
} else {
// Any other warning gets its own ticket. Previously every other
// HEALTH_WARN shared one empty subtype, so unrelated warnings
// (noout, nearfull, insecure keys, ...) collapsed onto a single
// ticket and overwrote its title in turn every run (#112).
// Prefer Ceph's own health-check code when the reporter sends
// it; otherwise key on the warning text with counts stripped.
$checkCode = isset($data['check_code']) && is_string($data['check_code']) ? $data['check_code'] : '';
if ($checkCode !== '' && preg_match('/^[A-Z0-9_]{1,64}$/', $checkCode)) {
$issueSubtype = 'check_' . strtolower($checkCode);
} elseif (preg_match('/HEALTH_WARN:\s*([^\[]+)/i', $title, $warnMatch)) {
$warnText = strtolower(explode(':', $warnMatch[1])[0]);
$warnText = trim(preg_replace('/[^a-z]+/', '_', $warnText), '_');
if ($warnText !== '') {
$issueSubtype = 'warn_' . $warnText;
}
}
}
}
@@ -328,10 +345,17 @@ for ($dedupAttempt = 1; $dedupAttempt <= $maxDedupAttempts; $dedupAttempt++) {
$commentStmt->close();
}
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
array_diff_key($changes, ['description_refreshed' => true]),
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
));
// Audit only real title/priority changes. The description is
// refreshed on every run, and logging that alone wrote a
// reason-only row every few minutes per open ticket — 50k+ rows
// on one ticket, which broke its page (tinker_tickets#112).
$auditedChanges = array_diff_key($changes, ['description_refreshed' => true]);
if (!empty($auditedChanges)) {
$auditLog->log($userId, 'update', 'ticket', $existingId, array_merge(
$auditedChanges,
['reason' => 'auto-updated by hwmonDaemon (condition worsened)']
));
}
// Only notify on priority escalation — title-only updates (e.g. rising
// Power_On_Hours counter) should not generate a Matrix ping every hour.
@@ -581,4 +605,8 @@ for ($dedupAttempt = 1; $dedupAttempt <= $maxDedupAttempts; $dedupAttempt++) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Internal server error']);
}
// Done — only the deadlock `continue` above may run the loop again.
// Falling through re-entered it on a closed connection, appending a
// 500 error body after the success response for every new ticket.
exit;
}
+5 -1
View File
@@ -28,6 +28,8 @@ require_once dirname(__DIR__) . '/controllers/ApiTicketController.php';
require_once dirname(__DIR__) . '/services/TicketCreationService.php';
require_once dirname(__DIR__) . '/services/CommentService.php';
require_once dirname(__DIR__) . '/services/AssignmentService.php';
require_once dirname(__DIR__) . '/services/DependencyService.php';
require_once dirname(__DIR__) . '/services/SimilarTicketService.php';
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Mcp\Server;
@@ -138,7 +140,9 @@ $transport = new StreamableHttpTransport(
new IdentityMiddleware($conn, $psr17, $psr17),
new ToolScopeMiddleware(
$psr17,
$canonical['scheme'] . '://' . $canonical['host'] . $metadata->getPrimaryMetadataPath()
$canonical['scheme'] . '://' . $canonical['host']
. (isset($canonical['port']) ? ':' . $canonical['port'] : '')
. $metadata->getPrimaryMetadataPath()
),
],
);
+7 -3
View File
@@ -15,7 +15,7 @@ use TinkerTickets\Mcp\Tools\TicketWriteTools;
final class ToolCatalog
{
/** Tool names that mutate data and require tickets:write. */
private const WRITE_TOOLS = ['create_ticket', 'add_comment', 'update_status', 'assign_ticket'];
private const WRITE_TOOLS = ['create_ticket', 'add_comment', 'update_status', 'assign_ticket', 'link_tickets', 'unlink_tickets'];
public static function isWriteTool(string $name): bool
{
@@ -27,16 +27,20 @@ final class ToolCatalog
$read = new TicketReadTools($conn);
$write = new TicketWriteTools($conn);
$readOnly = new ToolAnnotations(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false);
// Writes change tickets (and notify people) but never delete anything.
// Writes change tickets (and notify people); only unlink_tickets removes anything.
$additive = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false);
$update = new ToolAnnotations(readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false);
$remove = new ToolAnnotations(readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false);
return $builder
->addTool([$read, 'searchTickets'], 'search_tickets', 'Search tickets', null, $readOnly)
->addTool([$read, 'getTicket'], 'get_ticket', 'Get ticket', null, $readOnly)
->addTool([$read, 'findSimilarTickets'], 'find_similar_tickets', 'Find similar tickets', null, $readOnly)
->addTool([$write, 'createTicket'], 'create_ticket', 'Create ticket', null, $additive)
->addTool([$write, 'addComment'], 'add_comment', 'Add comment', null, $additive)
->addTool([$write, 'updateStatus'], 'update_status', 'Update ticket status', null, $update)
->addTool([$write, 'assignTicket'], 'assign_ticket', 'Assign ticket', null, $update);
->addTool([$write, 'assignTicket'], 'assign_ticket', 'Assign ticket', null, $update)
->addTool([$write, 'linkTickets'], 'link_tickets', 'Link tickets', null, $additive)
->addTool([$write, 'unlinkTickets'], 'unlink_tickets', 'Unlink tickets', null, $remove);
}
}
+97
View File
@@ -96,6 +96,11 @@ final class TicketReadTools
/**
* Get one ticket's full details and its comments.
*
* `links` lists the ticket's relationships from this ticket's point of view:
* blocks, blocked_by, relates_to, duplicates (this ticket duplicates the
* other) and duplicated_by (the other ticket duplicates this one). `blocked`
* is true while any blocked_by ticket is not Closed.
*
* @param string $ticket_id The ticket ID (digits only, e.g. "123456789").
* @param bool $include_comments Include the ticket's comments, newest first.
*
@@ -121,6 +126,12 @@ final class TicketReadTools
'updated_by' => $ticket['updater_display_name'] ?? $ticket['updater_username'] ?? null,
];
$details['links'] = $this->links($ticketId, $user);
$details['blocked'] = (bool)array_filter(
$details['links'],
fn(array $l) => $l['relation'] === 'blocked_by' && $l['status'] !== 'Closed'
);
if ($include_comments) {
$comments = (new \CommentModel($this->conn))->getCommentsByTicketId($ticketId, false);
$details['comment_count'] = count($comments);
@@ -139,6 +150,92 @@ final class TicketReadTools
return $details;
}
/**
* Find open tickets with titles similar to the given text, or to an
* existing ticket's title — the same check as the "possible duplicates"
* list on the ticket page. Title matching only; use search_tickets for
* keyword search over descriptions. To record a duplicate, call
* link_tickets with relation "duplicates".
*
* @param string|null $title Title text to compare against (at least 5 characters).
* @param string|null $ticket_id Or: an existing ticket whose title to compare; it is left out of the results.
* @param int $limit Maximum matches, 1-10.
*
* @return array<string, mixed>
*/
public function findSimilarTickets(?string $title = null, ?string $ticket_id = null, int $limit = 5): array
{
$user = McpIdentity::user();
$excludeId = null;
if ($ticket_id !== null && trim($ticket_id) !== '') {
$ticketModel = new \TicketModel($this->conn);
$excludeId = trim($ticket_id);
$ticket = preg_match('/^\d+$/', $excludeId) ? $ticketModel->getTicketById($excludeId) : null;
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $user)) {
throw new ToolCallException("Ticket {$excludeId} not found");
}
$title = (string)$ticket['title'];
}
if ($title === null || strlen(trim($title)) < 5) {
throw new ToolCallException('Provide a title of at least 5 characters, or a ticket_id');
}
$limit = min(10, max(1, $limit));
// One extra so excluding the ticket itself still leaves $limit matches.
$matches = \SimilarTicketService::find($this->conn, $user, $title, $limit + 1);
$matches = array_values(array_filter($matches, fn(array $m) => (string)$m['ticket_id'] !== $excludeId));
return [
'title' => trim($title),
'matches' => array_map(fn(array $m) => [
'ticket_id' => (string)$m['ticket_id'],
'title' => $m['title'],
'status' => $m['status'],
'priority' => (int)$m['priority'],
'similarity' => (int)$m['similarity'],
'url' => \UrlHelper::ticketUrl((string)$m['ticket_id']),
], array_slice($matches, 0, $limit)),
];
}
/**
* A ticket's links in both directions, each phrased from this ticket's
* side. Linked tickets the user can't see are left out by the service.
*
* @param array<string, mixed> $user
*
* @return list<array<string, mixed>>
*/
private function links(string $ticketId, array $user): array
{
$result = \DependencyService::list($this->conn, $user, $ticketId);
if (!$result['success']) {
throw new ToolCallException($result['error']);
}
// Stored as "<dependent> <type> <this ticket>"; restate from this side.
$reverse = ['blocks' => 'blocked_by', 'blocked_by' => 'blocks', 'relates_to' => 'relates_to', 'duplicates' => 'duplicated_by'];
$rows = [];
foreach ($result['dependencies'] as $type => $deps) {
foreach ($deps as $d) {
$rows[] = [$type, (string)$d['depends_on_id'], $d];
}
}
foreach ($result['dependents'] as $d) {
$rows[] = [$reverse[$d['dependency_type']] ?? $d['dependency_type'], (string)$d['ticket_id'], $d];
}
return array_map(fn(array $r) => [
'relation' => $r[0],
'ticket_id' => $r[1],
'title' => $r[2]['title'] ?? null,
'status' => $r[2]['status'] ?? null,
'priority' => isset($r[2]['priority']) ? (int)$r[2]['priority'] : null,
'url' => \UrlHelper::ticketUrl($r[1]),
], $rows);
}
/**
* @param array<string, mixed> $user
*/
+104 -1
View File
@@ -8,7 +8,7 @@ use TinkerTickets\Mcp\Auth\McpIdentity;
/**
* Write tools. Each one is a thin adapter over the exact code path the web UI
* uses (TicketCreationService, CommentService, ApiTicketController,
* AssignmentService), run as the signed-in user, so permissions, workflow
* AssignmentService, DependencyService), run as the signed-in user, so permissions, workflow
* rules, audit entries, notifications and stats-cache invalidation are
* identical. Gated by tickets:write in ToolScopeMiddleware (see ToolCatalog).
*/
@@ -183,6 +183,109 @@ final class TicketWriteTools
return ['ticket_id' => trim($ticket_id), 'assigned_to' => $assignedTo === null ? null : $target];
}
/**
* Link two tickets, as shown on the ticket page's Dependencies tab. Read it
* as "ticket_id <relation> other_ticket_id":
* - blocks: ticket_id must be done before other_ticket_id.
* - blocked_by: ticket_id is waiting on other_ticket_id.
* - relates_to: the tickets are related.
* - duplicates: ticket_id is a duplicate of other_ticket_id (the original).
* This only records the link; close the duplicate separately with
* update_status if that's wanted.
* Circular blocking chains and links that already exist (from either side)
* are rejected.
*
* @param string $ticket_id The ticket the relation is stated from.
* @param string $relation One of: blocks, blocked_by, relates_to, duplicates.
* @param string $other_ticket_id The other ticket.
*
* @return array<string, mixed>
*/
public function linkTickets(string $ticket_id, string $relation, string $other_ticket_id): array
{
$user = McpIdentity::user();
[$ticketId, $otherId, $relation] = $this->linkArgs($ticket_id, $relation, $other_ticket_id);
$result = \DependencyService::add($this->conn, $user, [
'ticket_id' => $ticketId,
'depends_on_id' => $otherId,
'dependency_type' => $relation,
]);
if (empty($result['success'])) {
if (($result['error'] ?? '') === 'Target ticket not found') {
throw new ToolCallException("Ticket {$otherId} not found");
}
throw new ToolCallException($this->errorMessage($result, $ticketId));
}
return ['ticket_id' => $ticketId, 'relation' => $relation, 'other_ticket_id' => $otherId];
}
/**
* Remove a link between two tickets. Takes the same arguments as
* link_tickets; a link recorded from the other ticket's side ("B blocked_by A"
* for "A blocks B") is found and removed too.
*
* @param string $ticket_id The ticket the relation is stated from.
* @param string $relation One of: blocks, blocked_by, relates_to, duplicates.
* @param string $other_ticket_id The other ticket.
*
* @return array<string, mixed>
*/
public function unlinkTickets(string $ticket_id, string $relation, string $other_ticket_id): array
{
$user = McpIdentity::user();
[$ticketId, $otherId, $relation] = $this->linkArgs($ticket_id, $relation, $other_ticket_id);
// The same relationship can be stored from either ticket. duplicates
// has no inverse type, so it is only ever stored one way.
$inverse = ['blocks' => 'blocked_by', 'blocked_by' => 'blocks', 'relates_to' => 'relates_to'];
$attempts = [[$ticketId, $otherId, $relation]];
if (isset($inverse[$relation])) {
$attempts[] = [$otherId, $ticketId, $inverse[$relation]];
}
$removed = 0;
foreach ($attempts as [$from, $to, $type]) {
$result = \DependencyService::remove($this->conn, $user, [
'ticket_id' => $from,
'depends_on_id' => $to,
'dependency_type' => $type,
]);
if (empty($result['success'])) {
throw new ToolCallException($this->errorMessage($result, $from));
}
$removed += (int)$result['removed'];
}
if ($removed === 0) {
throw new ToolCallException("No {$relation} link between {$ticketId} and {$otherId}");
}
return ['ticket_id' => $ticketId, 'relation' => $relation, 'other_ticket_id' => $otherId, 'removed' => true];
}
/**
* @return array{0:string,1:string,2:string}
*/
private function linkArgs(string $ticketId, string $relation, string $otherId): array
{
$ticketId = trim($ticketId);
$otherId = trim($otherId);
$relation = strtolower(trim($relation));
if (!in_array($relation, \DependencyService::TYPES, true)) {
throw new ToolCallException('relation must be one of: ' . implode(', ', \DependencyService::TYPES));
}
foreach ([$ticketId, $otherId] as $id) {
if (!ctype_digit($id)) {
throw new ToolCallException("Ticket {$id} not found");
}
}
if ($ticketId === $otherId) {
throw new ToolCallException('A ticket cannot be linked to itself');
}
return [$ticketId, $otherId, $relation];
}
/**
* @param array<string, mixed> $user
*/
+10 -3
View File
@@ -561,20 +561,27 @@ class AuditLogModel
* Get formatted timeline for a specific ticket
* Includes all ticket updates and comments
*
* Returns only the newest $limit events: a ticket touched by an automated
* reporter can accumulate tens of thousands of rows, and loading them all
* exhausted PHP memory on the ticket page (tinker_tickets#112).
*
* @param string $ticketId Ticket ID
* @param int $limit Maximum number of events (newest first)
* @return array Timeline events
*/
public function getTicketTimeline($ticketId)
public function getTicketTimeline($ticketId, $limit = self::DEFAULT_LIMIT)
{
$limit = $this->validateLimit((int)$limit, self::EXPORT_LIMIT);
$stmt = $this->conn->prepare(
"SELECT al.*, u.username, u.display_name
FROM audit_log al
LEFT JOIN users u ON al.user_id = u.user_id
WHERE (al.entity_type = 'ticket' AND al.entity_id = ?)
OR (al.entity_type = 'comment' AND JSON_UNQUOTE(JSON_EXTRACT(al.details, '$.ticket_id')) = ?)
ORDER BY al.created_at DESC"
ORDER BY al.created_at DESC, al.audit_id DESC
LIMIT ?"
);
$stmt->bind_param("ss", $ticketId, $ticketId);
$stmt->bind_param("ssi", $ticketId, $ticketId, $limit);
$stmt->execute();
$result = $stmt->get_result();
+4 -4
View File
@@ -219,14 +219,14 @@ class DependencyModel
* Remove a dependency
*
* @param int $dependencyId Dependency ID
* @return bool Success status
* @return int|false Rows removed (0 if it no longer existed), or false on failure
*/
public function removeDependency($dependencyId)
{
$sql = "DELETE FROM ticket_dependencies WHERE dependency_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("i", $dependencyId);
$result = $stmt->execute();
$result = $stmt->execute() ? $stmt->affected_rows : false;
$stmt->close();
return $result;
}
@@ -237,7 +237,7 @@ class DependencyModel
* @param string $ticketId Source ticket ID
* @param string $dependsOnId Target ticket ID
* @param string $type Dependency type
* @return bool Success status
* @return int|false Rows removed (0 if no such link), or false on failure
*/
public function removeDependencyByTickets($ticketId, $dependsOnId, $type)
{
@@ -245,7 +245,7 @@ class DependencyModel
WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("sss", $ticketId, $dependsOnId, $type);
$result = $stmt->execute();
$result = $stmt->execute() ? $stmt->affected_rows : false;
$stmt->close();
return $result;
}
+180
View File
@@ -0,0 +1,180 @@
<?php
/**
* Listing, adding and removing ticket dependencies (blocks / blocked_by /
* relates_to / duplicates): access checks on the tickets involved,
* DependencyModel's duplicate/inverse/cycle checks, and audit logging.
*
* Shared by the web UI (api/ticket_dependencies.php) and the MCP
* link_tickets / unlink_tickets / get_ticket tools so both run one code path.
* Extracted from ticket_dependencies.php with the same checks, order and
* error messages. Returns result arrays; failures carry 'http_status' for
* HTTP callers. Callers own sessions/CSRF/responses.
*/
require_once dirname(__DIR__) . '/models/DependencyModel.php';
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
require_once dirname(__DIR__) . '/models/TicketModel.php';
class DependencyService
{
public const TYPES = ['blocks', 'blocked_by', 'relates_to', 'duplicates'];
/**
* Links from and to a ticket, limited to linked tickets the user can see.
*
* @param array $currentUser Authenticated user row (user_id, groups, is_admin)
* @return array ['success' => true, 'dependencies' => [type => rows], 'dependents' => rows]
*/
public static function list(mysqli $conn, array $currentUser, $ticketId): array
{
if (!$ticketId) {
return ['success' => false, 'error' => 'Ticket ID required', 'http_status' => 400];
}
// Verify user can access this ticket
$ticketModel = new TicketModel($conn);
$ticket = $ticketModel->getTicketById($ticketId);
if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) {
return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404];
}
$dependencyModel = new DependencyModel($conn);
[$userId, $userGroups, $isAdmin] = self::viewer($currentUser);
try {
$dependencies = $dependencyModel->getDependencies($ticketId, $userId, $userGroups, $isAdmin);
$dependents = $dependencyModel->getDependentTickets($ticketId, $userId, $userGroups, $isAdmin);
} catch (Exception $e) {
error_log('DependencyService::list query error: ' . $e->getMessage());
return ['success' => false, 'error' => 'Failed to retrieve dependencies', 'http_status' => 500];
}
return ['success' => true, 'dependencies' => $dependencies, 'dependents' => $dependents];
}
/**
* @param array $data ticket_id, depends_on_id, dependency_type (default "blocks")
* @return array ['success' => true, 'dependency_id' => int] or an error
*/
public static function add(mysqli $conn, array $currentUser, array $data): array
{
$ticketId = $data['ticket_id'] ?? null;
$dependsOnId = $data['depends_on_id'] ?? null;
$type = $data['dependency_type'] ?? 'blocks';
if (!$ticketId || !$dependsOnId) {
return ['success' => false, 'error' => 'Both ticket_id and depends_on_id are required', 'http_status' => 400];
}
// Verify user can access both tickets before creating dependency
$ticketModel = new TicketModel($conn);
$srcTicket = $ticketModel->getTicketById($ticketId);
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404];
}
$tgtTicket = $ticketModel->getTicketById($dependsOnId);
if (!$tgtTicket || !$ticketModel->canUserAccessTicket($tgtTicket, $currentUser)) {
return ['success' => false, 'error' => 'Target ticket not found', 'http_status' => 404];
}
$result = (new DependencyModel($conn))->addDependency($ticketId, $dependsOnId, $type, $currentUser['user_id']);
if (!$result['success']) {
return ['success' => false, 'error' => $result['error'], 'http_status' => 400];
}
(new AuditLogModel($conn))->log($currentUser['user_id'], 'create', 'dependency', (string)$result['dependency_id'], [
'ticket_id' => $ticketId,
'depends_on_id' => $dependsOnId,
'type' => $type
]);
return $result;
}
/**
* Remove one link, by dependency_id or by (ticket_id, depends_on_id, dependency_type).
*
* @return array ['success' => true, 'removed' => rows deleted] or an error
*/
public static function remove(mysqli $conn, array $currentUser, array $data): array
{
$dependencyModel = new DependencyModel($conn);
$ticketModel = new TicketModel($conn);
$auditLog = new AuditLogModel($conn);
$dependencyId = $data['dependency_id'] ?? null;
// Alternative: delete by ticket IDs
if (!$dependencyId && isset($data['ticket_id']) && isset($data['depends_on_id'])) {
$ticketId = $data['ticket_id'];
$dependsOnId = $data['depends_on_id'];
$type = $data['dependency_type'] ?? 'blocks';
// Validate dependency type
if (!in_array($type, self::TYPES, true)) {
return ['success' => false, 'error' => 'Invalid dependency type', 'http_status' => 400];
}
// Verify user can access the source ticket
$srcTicket = $ticketModel->getTicketById($ticketId);
if (!$srcTicket || !$ticketModel->canUserAccessTicket($srcTicket, $currentUser)) {
return ['success' => false, 'error' => 'Ticket not found', 'http_status' => 404];
}
$removed = $dependencyModel->removeDependencyByTickets($ticketId, $dependsOnId, $type);
if ($removed === false) {
return ['success' => false, 'error' => 'Failed to remove dependency', 'http_status' => 400];
}
// Only audit a removal that happened (the web UI treats removing
// a link that is already gone as success).
if ($removed > 0) {
$auditLog->log($currentUser['user_id'], 'delete', 'dependency', null, [
'ticket_id' => $ticketId,
'depends_on_id' => $dependsOnId,
'type' => $type
]);
}
return ['success' => true, 'removed' => $removed];
}
if (!$dependencyId) {
return ['success' => false, 'error' => 'Dependency ID or ticket IDs required', 'http_status' => 400];
}
// Look up dependency to verify ticket access before deletion
$depLookupStmt = $conn->prepare("SELECT ticket_id FROM ticket_dependencies WHERE dependency_id = ?");
$depLookupStmt->bind_param("i", $dependencyId);
$depLookupStmt->execute();
$depRow = $depLookupStmt->get_result()->fetch_assoc();
$depLookupStmt->close();
if (!$depRow) {
return ['success' => false, 'error' => 'Dependency not found', 'http_status' => 404];
}
$depTicket = $ticketModel->getTicketById($depRow['ticket_id']);
if (!$depTicket || !$ticketModel->canUserAccessTicket($depTicket, $currentUser)) {
return ['success' => false, 'error' => 'Access denied', 'http_status' => 403];
}
$removed = $dependencyModel->removeDependency($dependencyId);
if ($removed === false) {
return ['success' => false, 'error' => 'Failed to remove dependency', 'http_status' => 400];
}
$auditLog->log($currentUser['user_id'], 'delete', 'dependency', (string)$dependencyId);
return ['success' => true, 'removed' => $removed];
}
/**
* users.groups is a comma-separated string; the dependency model expects an array.
*
* @return array{0:mixed,1:array,2:bool}
*/
private static function viewer(array $currentUser): array
{
return [
$currentUser['user_id'] ?? null,
array_values(array_filter(array_map('trim', explode(',', $currentUser['groups'] ?? '')))),
(bool)($currentUser['is_admin'] ?? false),
];
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
/**
* Finding open tickets whose titles look like a given title (LIKE + SOUNDEX +
* word overlap), limited to tickets the user can see, best match first.
*
* Shared by the web UI (api/check_duplicates.php, the ticket page's
* "possible duplicates" list) and the MCP find_similar_tickets tool.
* Extracted from check_duplicates.php.
*/
require_once dirname(__DIR__) . '/models/TicketModel.php';
class SimilarTicketService
{
/**
* @param array $currentUser Authenticated user row (for visibility)
* @return array Matches: ticket_id, title, status, priority, created_at, similarity (0-100)
*/
public static function find(mysqli $conn, array $currentUser, string $title, int $limit = 5): array
{
$title = trim($title);
if (strlen($title) < 5) {
return [];
}
// Search for similar titles
// Use both LIKE for substring matching and SOUNDEX for phonetic matching
$duplicates = [];
// Prepare search term for LIKE
$searchTerm = '%' . $title . '%';
$titleWords = self::words($title);
// Build visibility filter so users only see titles they have access to
$ticketModel = new TicketModel($conn);
$visFilter = $ticketModel->getVisibilityFilter($currentUser);
// Candidates: the whole title as a substring, a SOUNDEX match, or any
// significant word (4+ letters) in common. The scoring below decides
// what counts as similar; without the word candidates its
// word-overlap branch could never match anything, so e.g. "Printer
// jammed again" never surfaced "Printer is jammed".
$words = array_slice(array_values(array_filter($titleWords, fn($w) => mb_strlen($w) >= 4)), 0, 8);
$wordSql = str_repeat(' OR t.title LIKE ?', count($words));
$wordParams = array_map(fn($w) => '%' . addcslashes($w, '%_\\') . '%', $words);
// Aliased as `t`: the visibility filter's SQL refers to t.* columns.
// Without the alias the query failed for every non-admin and the
// error was swallowed below, so they never saw any matches.
// SOUNDEX is compared in SQL on both sides: PHP's soundex() keeps only
// 4 characters (effectively the first word), which never equalled
// MariaDB's full-length code in the WHERE and, in the scoring, made any
// two titles sharing a first word score as "sounds alike".
$sql = "SELECT t.ticket_id, t.title, t.status, t.priority, t.created_at,
SOUNDEX(t.title) = SOUNDEX(?) AS sounds_alike
FROM tickets t
WHERE (
t.title LIKE ?
OR SOUNDEX(t.title) = SOUNDEX(?){$wordSql}
)
AND t.status != 'Closed'
AND ({$visFilter['sql']})
ORDER BY t.created_at DESC
LIMIT 50";
$types = "sss" . str_repeat('s', count($words)) . $visFilter['types'];
$params = array_merge([$title, $searchTerm, $title], $wordParams, $visFilter['params']);
// Duplicate detection is advisory (it must not block ticket creation), so on any
// DB error degrade gracefully to "no duplicates" rather than fataling the request.
// mysqli may throw (default exception mode) or return false depending on config.
try {
$stmt = $conn->prepare($sql);
if (!$stmt) {
throw new RuntimeException('prepare failed: ' . $conn->error);
}
if (!empty($params)) {
$stmt->bind_param($types, ...$params);
}
$stmt->execute();
$result = $stmt->get_result();
if ($result === false) {
// Non-exception mysqli mode: execute/get_result return false instead of
// throwing. Treat as a query failure so we don't fatal on $result below.
throw new RuntimeException('query failed: ' . $conn->error);
}
} catch (Throwable $e) {
error_log('check_duplicates: ' . $e->getMessage());
return [];
}
while ($row = $result->fetch_assoc()) {
// Calculate similarity score
$similarity = 0;
// Check for exact substring match
if (stripos($row['title'], $title) !== false) {
$similarity = 90;
// Check SOUNDEX match
} elseif (!empty($row['sounds_alike'])) {
$similarity = 70;
// Check word overlap
} else {
// At least two shared words: one common word (e.g. the
// "[problem]" tag every automated ticket carries) is not
// similarity, however short the searched title is.
$matchingWords = array_intersect($titleWords, self::words($row['title']));
if (count($matchingWords) >= 2) {
$similarity = (count($matchingWords) / max(count($titleWords), 1)) * 60;
}
}
if ($similarity >= 30) {
$duplicates[] = [
'ticket_id' => $row['ticket_id'],
'title' => $row['title'],
'status' => $row['status'],
'priority' => $row['priority'],
'created_at' => $row['created_at'],
'similarity' => round($similarity)
];
}
}
$stmt->close();
// Sort by similarity descending
usort($duplicates, function ($a, $b) {
return $b['similarity'] - $a['similarity'];
});
// Keep the best matches
return array_slice($duplicates, 0, $limit);
}
/**
* Distinct lowercase words of a title, split on anything that isn't a
* letter or digit (so "[ceph]" and "ceph" are the same word).
*
* @return list<string>
*/
private static function words(string $title): array
{
return array_values(array_unique(array_filter(
preg_split('/[^\p{L}\p{N}]+/u', mb_strtolower($title)),
fn($w) => $w !== ''
)));
}
}
+3
View File
@@ -819,6 +819,9 @@ document.addEventListener('DOMContentLoaded', function() {
</div>
<?php endforeach ?>
</div>
<?php if (!empty($timelineTruncated)) : ?>
<div class="lt-empty lt-text-xs">Showing the <?= count($timeline) ?> most recent events; older activity is in the audit log.</div>
<?php endif ?>
<?php endif ?>
</div>
</div>