Compare commits

...
Author SHA1 Message Date
jared a7f684626c Fix lint: make scripts/demo/router.php PSR-12 compliant; tidy demo scripts
Lint / PHP (phpcs PSR-12) (push) Successful in 33s
Lint / JS (eslint) (push) Successful in 13s
Lint / PHP requirements (version + extensions) (push) Successful in 56s
Lint / Notify on failure (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 2m14s
Lint / Deploy (push) Successful in 3s
2026-10-03 16:59:02 -04:00
jared 051ef6c915 README: add walkthrough GIF, gallery and architecture diagram; add demo seed and capture scripts
Lint / PHP (phpcs PSR-12) (push) Failing after 31s
Lint / JS (eslint) (push) Successful in 8s
Lint / PHP requirements (version + extensions) (push) Successful in 22s
Lint / Deploy (push) Skipped
Security / PHP Security (semgrep) (push) Successful in 1m17s
Lint / Notify on failure (push) Successful in 8s
2026-10-03 01:02:55 -04:00
jared 13a0612f23 Fix migration runner: strip -- comments before splitting statements
A semicolon inside a SQL comment (as in 001 and 003) cut statements in half and
made those migrations fail on a fresh database.
2026-10-03 01:02:55 -04:00
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
37 changed files with 1303 additions and 251 deletions
+1
View File
@@ -9,3 +9,4 @@ uploads/*
!uploads/.htaccess
# Composer dependencies (used only by the MCP endpoint; installed at deploy time)
vendor/
scripts/demo/__pycache__/
+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.
+104 -1
View File
@@ -8,6 +8,62 @@ A feature-rich PHP-based ticketing system designed for tracking and managing dat
**Documentation**: [Wiki](https://wiki.lotusguild.org/en/Services/service-tinker-tickets)
**Design System**: [web_template](https://code.lotusguild.org/LotusGuild/web_template) — shared CSS, JS, and layout patterns for all LotusGuild apps
![Tinker Tickets walkthrough: dashboard, kanban, ticket detail with SLA timer, comments, dependencies, activity timeline, command palette and workflow designer](docs/img/demo.gif)
## Why this exists
Running a six-node Proxmox/Ceph cluster generates a steady stream of small problems: a drive with SMART warnings, a link that flaps, a certificate about to expire. Off-the-shelf helpdesks are heavyweight for that, and a plain todo list loses the history. Tinker Tickets is a small, opinionated ticketing system built for exactly this: tickets are created **automatically by monitoring** (and by hand), deduplicated so a repeating alert updates one ticket instead of flooding the queue, and every change is audited.
```mermaid
flowchart LR
hw["hwmonDaemon<br/>SMART, Ceph, temps"] -->|Bearer API| tt
gd["GANDALF<br/>network regressions"] -->|Bearer API| tt
mcp["MCP server<br/>Claude Code / agents (OAuth)"] --> tt
ui["Web UI<br/>people"] --> tt
tt{{"Tinker Tickets<br/>PHP + MariaDB"}} -->|webhook| mx["Matrix notifications"]
tt --> audit[("Audit log<br/>timeline per ticket")]
```
## Gallery
**Dashboard**: live stat cards, priority and status breakdowns, team workload and a filterable queue.
![Dashboard](docs/img/dashboard.png)
**Kanban view** with per-column counts and priority-coloured cards:
![Kanban board](docs/img/kanban.png)
**Ticket detail** with a live P1/P2 SLA timer, full metadata and a tabbed workspace:
![Ticket with SLA banner](docs/img/ticket-sla.png)
| Comments (Markdown, @mentions) | Dependencies (blocks / relates to) |
|---|---|
| ![Comments](docs/img/ticket-comments.png) | ![Dependencies](docs/img/ticket-dependencies.png) |
**Activity timeline**, derived from the audit log (creation, assignment, status changes, comments):
![Activity timeline](docs/img/ticket-activity.png)
**Command palette** (`Ctrl+K`) jumps to pages, saved filters and recently viewed tickets:
![Command palette](docs/img/command-palette.png)
**Admin tools:**
| Workflow designer | API keys (scoped, revocable) |
|---|---|
| ![Workflow designer](docs/img/admin-workflow.png) | ![API keys](docs/img/admin-api-keys.png) |
| **Audit log** | **Recurring tickets** |
| ![Audit log](docs/img/admin-audit-log.png) | ![Recurring tickets](docs/img/admin-recurring.png) |
**Light theme** (the whole app follows the [web_template](https://code.lotusguild.org/LotusGuild/web_template) theme toggle):
![Dashboard, light theme](docs/img/dashboard-light.png)
<sub>Screenshots are generated with Playwright against a throwaway local instance seeded with fictional demo data (`scripts/demo/`).</sub>
## Styling & Layout
Tinker Tickets uses the **LotusGuild Terminal Design System**. For all styling, component, and layout documentation see:
@@ -113,6 +169,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 +441,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 +487,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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

+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
*/
+5 -2
View File
@@ -141,9 +141,12 @@ foreach ($pending as $file) {
// DDL.
try {
// Split by semicolon but respect statements properly
// Note: This doesn't handle semicolons in strings, but our migrations are simple
// Note: This doesn't handle semicolons in strings, but our migrations are simple.
// Full-line "--" comments are stripped first so a ";" inside a comment
// can't cut a statement in half.
$sqlNoComments = preg_replace('/^\s*--.*$/m', '', $sql);
$statements = array_values(array_filter(
array_map('trim', explode(';', $sql)),
array_map('trim', explode(';', $sqlNoComments)),
function($stmt) {
// Remove comments and check if there's actual SQL
$cleaned = preg_replace('/--.*$/m', '', $stmt);
+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;
}
+19
View File
@@ -0,0 +1,19 @@
# Demo environment (for README screenshots)
Everything here uses **fictional data only**. It spins up a throwaway MariaDB on a private socket and PHP's
built-in server, so nothing touches a real database.
```bash
D=$(mktemp -d); cp -r . $D/tinker && cp scripts/demo/router.php scripts/demo/seed.py scripts/demo/capture.py $D/
cd $D
printf 'DB_HOST=localhost\nDB_USER=root\nDB_PASS=\nDB_NAME=ticketing_system\n' > tinker/.env
mariadb-install-db --no-defaults --datadir=$D/db/data --auth-root-authentication-method=normal --skip-test-db
mariadbd --no-defaults --datadir=$D/db/data --socket=$D/db/sock --skip-networking --user=root &
mariadb --no-defaults -S $D/db/sock -e 'CREATE DATABASE ticketing_system CHARACTER SET utf8mb4'
(cd tinker && php -d mysqli.default_socket=$D/db/sock migrations/migrate.php)
python3 seed.py && mariadb --no-defaults -S $D/db/sock ticketing_system < seed.sql
php -d mysqli.default_socket=$D/db/sock -S 127.0.0.1:8300 -t tinker router.php &
python3 capture.py # screenshots + GIF -> docs/img/
```
The app trusts Authelia `Remote-User` headers, so `capture.py` sends them directly. Requires `playwright` and `pillow`.
+174
View File
@@ -0,0 +1,174 @@
# flake8: noqa: E501 (demo data and canned output contain long literal lines)
import asyncio
import io
import sys
from PIL import Image
from playwright.async_api import async_playwright
import pathlib
OUT = str(pathlib.Path(__file__).resolve().parents[2] / "docs" / "img")
B = "http://127.0.0.1:8300"
H = {
"Remote-User": "alex.rivera",
"Remote-Name": "Alex Rivera",
"Remote-Email": "alex@example.com",
"Remote-Groups": "admin,employee",
}
P1 = "152847156"
CEPH = "524938499"
JS_TOP = (
"document.activeElement&&document.activeElement.blur();"
"document.documentElement.style.scrollBehavior='auto';window.scrollTo(0,0)"
)
async def ctxmk(p, theme="dark", w=1440, h=900):
b = await p.chromium.launch()
c = await b.new_context(viewport={"width": w, "height": h}, extra_http_headers=H)
await c.add_init_script(f"try{{localStorage.setItem('lt_theme','{theme}')}}catch(e){{}}")
async def avatar(route):
import re
uid = int((re.search(r"user_id=(\d+)", route.request.url) or [0, 0])[1] or 0)
ini = {1: "AR", 2: "SC", 3: "RB", 4: "JK", 5: "HW"}.get(uid, "??")
col = {1: "#ff6b00", 2: "#00d4ff", 3: "#b44aff", 4: "#00ff88", 5: "#ffc800"}.get(uid, "#888")
svg = f'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#050a10"/><text x="32" y="40" font-family="monospace" font-size="24" font-weight="700" fill="{col}" text-anchor="middle">{ini}</text></svg>'
await route.fulfill(status=200, content_type="image/svg+xml", body=svg)
await c.route("**/api/user_avatar.php*", avatar)
return b, c
async def go(pg, path, wait=3500):
await pg.goto(B + path)
await pg.wait_for_timeout(wait)
await pg.evaluate(JS_TOP)
await pg.wait_for_timeout(300)
async def tab(pg, name):
t = pg.get_by_role("tab", name=name).first
if await t.count() == 0:
t = pg.get_by_text(name, exact=False).first
await t.click(timeout=4000)
await pg.wait_for_timeout(900)
async def shots():
async with async_playwright() as p:
b, c = await ctxmk(p)
pg = await c.new_page()
await go(pg, "/", 5000)
await pg.screenshot(path=f"{OUT}/dashboard.png")
# table crop
await pg.set_viewport_size({"width": 1840, "height": 900})
await pg.wait_for_timeout(600)
await pg.evaluate("document.querySelector('.lt-tabs, [role=tablist]')?.scrollIntoView({block:'start'})")
await pg.wait_for_timeout(500)
await pg.screenshot(path=f"{OUT}/ticket-queue.png")
await pg.set_viewport_size({"width": 1440, "height": 900})
await pg.wait_for_timeout(600)
try:
await tab(pg, "Kanban")
await pg.screenshot(path=f"{OUT}/kanban.png")
print("kanban ok")
except Exception as e:
print("kanban fail", str(e)[:80])
await go(pg, "/ticket/" + P1)
await pg.screenshot(path=f"{OUT}/ticket-sla.png")
await go(pg, "/ticket/" + CEPH)
for name, fn in (
("Comments", "ticket-comments"),
("Dependencies", "ticket-dependencies"),
("Activity", "ticket-activity"),
):
try:
await tab(pg, name)
await pg.evaluate("document.querySelector('.lt-tabs, [role=tablist]')?.scrollIntoView({block:'start'})")
await pg.wait_for_timeout(500)
await pg.screenshot(path=f"{OUT}/{fn}.png")
print(fn, "ok")
except Exception as e:
print(fn, "fail", str(e)[:80])
await go(pg, "/ticket/create", 3000)
await pg.screenshot(path=f"{OUT}/new-ticket.png")
await go(pg, "/")
await pg.keyboard.press("Control+k")
await pg.wait_for_timeout(500)
await pg.keyboard.type("ticket", delay=70)
await pg.wait_for_timeout(900)
await pg.screenshot(path=f"{OUT}/command-palette.png", clip={"x": 320, "y": 60, "width": 800, "height": 460})
await pg.keyboard.press("Escape")
for path, name in (
("/admin/workflow", "admin-workflow"),
("/admin/api-keys", "admin-api-keys"),
("/admin/audit-log", "admin-audit-log"),
("/admin/recurring-tickets", "admin-recurring"),
):
await go(pg, path, 3500)
await pg.screenshot(path=f"{OUT}/{name}.png")
print(name, "ok")
await b.close()
b, c = await ctxmk(p, "light")
pg = await c.new_page()
await go(pg, "/", 5000)
await pg.screenshot(path=f"{OUT}/dashboard-light.png")
await b.close()
async def gif():
frames = []
async with async_playwright() as p:
b, c = await ctxmk(p, "dark", 1280, 720)
pg = await c.new_page()
async def snap(n=1, ms=0):
if ms:
await pg.wait_for_timeout(ms)
im = Image.open(io.BytesIO(await pg.screenshot())).convert("RGB").resize((960, 540), Image.LANCZOS)
for _ in range(n):
frames.append(im)
await go(pg, "/", 4500)
await snap(3)
await pg.mouse.wheel(0, 520)
await snap(2, 500)
try:
await tab(pg, "Kanban")
await snap(3, 300)
except Exception:
pass
await go(pg, "/ticket/" + P1, 3000)
await snap(3)
await go(pg, "/ticket/" + CEPH, 3000)
await snap(2)
for name in ("Comments", "Dependencies", "Activity"):
try:
await tab(pg, name)
await snap(3, 300)
except Exception:
pass
await go(pg, "/", 3000)
await pg.keyboard.press("Control+k")
await snap(2, 500)
for ch in "tick":
await pg.keyboard.type(ch)
await snap(1, 200)
await snap(3, 300)
await go(pg, "/admin/workflow", 3000)
await snap(3)
await b.close()
pal = [f.quantize(colors=96, method=Image.MEDIANCUT, dither=Image.NONE) for f in frames]
pal[0].save(f"{OUT}/demo.gif", save_all=True, append_images=pal[1:], duration=1100, loop=0, optimize=True)
print("gif", len(frames))
async def main():
if "gif" not in sys.argv:
await shots()
if "shots" not in sys.argv:
await gif()
asyncio.run(main())
+16
View File
@@ -0,0 +1,16 @@
<?php
/**
* Router script for PHP's built-in web server, used only for the local screenshot demo.
* Serves real static files directly and sends everything else to the application's index.php.
*/
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$file = __DIR__ . '/tinker' . $path;
if ($path !== '/' && is_file($file) && !str_ends_with($file, '.php')) {
return false;
}
chdir(__DIR__ . '/tinker');
require __DIR__ . '/tinker/index.php';
+344
View File
@@ -0,0 +1,344 @@
# flake8: noqa: E501 (demo data and canned output contain long literal lines)
import hashlib
import json
import pathlib
import random
from datetime import datetime, timedelta
random.seed(7)
D = str(pathlib.Path(__file__).resolve().parent)
now = datetime.now().replace(microsecond=0)
def q(s):
return "'" + str(s).replace("\\", "\\\\").replace("'", "\\'") + "'"
def ts(h):
return q((now - timedelta(hours=h)).strftime("%Y-%m-%d %H:%M:%S"))
sql = []
users = [
(1, "alex.rivera", "Alex Rivera", "alex@example.com", "admin,employee", 1),
(2, "sam.chen", "Sam Chen", "sam@example.com", "employee", 0),
(3, "riley.brooks", "Riley Brooks", "riley@example.com", "employee", 0),
(4, "jordan.kim", "Jordan Kim", "jordan@example.com", "employee", 0),
(5, "hwmon-daemon", "hwmonDaemon", "", "employee", 0),
]
for u in users:
sql.append(
f"INSERT INTO users(user_id,username,display_name,email,`groups`,is_admin,last_login) VALUES({u[0]},{q(u[1])},{q(u[2])},{q(u[3])},{q(u[4])},{u[5]},{ts(1)});"
)
tr = [
("Open", "In Progress", 0, 0),
("Open", "Pending", 0, 0),
("Open", "Closed", 1, 0),
("Pending", "Open", 0, 0),
("Pending", "In Progress", 0, 0),
("Pending", "Closed", 1, 0),
("In Progress", "Pending", 0, 0),
("In Progress", "Open", 0, 0),
("In Progress", "Closed", 1, 0),
("Closed", "Open", 0, 1),
]
for a, b, c, d in tr:
sql.append(
f"INSERT INTO status_transitions(from_status,to_status,requires_comment,requires_admin) VALUES({q(a)},{q(b)},{c},{d});"
)
# (title, cat, type, status, prio, assignee, creator, age_h, desc)
T = [
(
"[storage-01][auto][hardware] Drive SN-4F21A8 has SMART issues [single-node][production][problem]",
"Hardware",
"Problem",
"Open",
2,
2,
5,
3,
"SMART reports 12 reallocated sectors and 1 pending sector on /dev/sdc.\n\n```\nReallocated_Sector_Ct 12\nCurrent_Pending_Sector 1\n```\nOSD.14 is backed by this drive. Plan a replacement in the next maintenance window.",
),
(
"[node-02][auto][hardware] CPU temperature sustained above 85C [single-node][production][problem]",
"Hardware",
"Problem",
"Open",
2,
None,
5,
5,
"15-minute average package temperature 87C (threshold 85C). Fans at 100%. Check airflow and heatsink seating.",
),
(
"[proxmox-cluster][auto][software] Ceph HEALTH_WARN: 3 slow ops on osd.9 [cluster-wide][production][problem]",
"Software",
"Problem",
"In Progress",
3,
1,
5,
26,
"`ceph health detail` reports slow BlueStore ops on osd.9. PGs remain active+clean. Triage steps in the Ceph runbook.",
),
(
"[node-03][auto][hardware] LXC storage pool above 85% usage [single-node][production][maintenance]",
"Hardware",
"Maintenance",
"Open",
3,
3,
5,
9,
"Pool plpSSDPool is at 86%. Grow or move guests before it reaches 90%.",
),
(
"[switch-02][auto][network] Link down on uplink port 14 [single-node][production][problem]",
"Network",
"Problem",
"Open",
1,
1,
5,
2,
"Port 14 (10GbE uplink to node-03) went down 2 hours ago. Traffic failed over to the backup path. Cable or SFP+ suspected.",
),
(
"[proxmox-cluster][manual][software] Upgrade Proxmox VE 8.3 to 8.4 [cluster-wide][production][upgrade]",
"Software",
"Upgrade",
"Pending",
3,
2,
1,
70,
"Rolling upgrade, one node at a time. Blocked until the Ceph warning on osd.9 is resolved.",
),
(
"[monitor-01][manual][software] Renew TLS certificate for status page [single-node][production][maintenance]",
"Software",
"Maintenance",
"In Progress",
4,
4,
1,
30,
"Certificate expires in 12 days. Renewal job failed on the DNS challenge.",
),
(
"[proxmox-cluster][manual][security] Rotate API keys used by monitoring agents [cluster-wide][production][task]",
"Security",
"Task",
"Open",
3,
1,
1,
48,
"Quarterly rotation. Keys: hwmonDaemon, gandalf, pulse workers.",
),
(
"[node-01][auto][hardware] Drive SN-88C0D2 has SMART issues [single-node][production][problem]",
"Hardware",
"Problem",
"Closed",
2,
2,
5,
150,
"Replaced during maintenance. OSD rebuilt, cluster healthy.",
),
(
"[proxmox-cluster][manual][configuration] Add Prometheus scrape target for new exporter [cluster-wide][production][task]",
"General",
"Task",
"Closed",
5,
3,
3,
200,
"Added and verified in Targets.",
),
(
"[node-02][manual][hardware] Replace failed NVMe in bay 2 [single-node][production][maintenance]",
"Hardware",
"Maintenance",
"Pending",
2,
2,
1,
52,
"Replacement ordered. Awaiting delivery.",
),
(
"[storage-01][auto][hardware] Memory ECC correctable errors increasing [single-node][production][problem]",
"Hardware",
"Problem",
"Open",
3,
None,
5,
14,
"EDAC reports 41 correctable errors on DIMM_B1 in 24 hours.",
),
(
"[proxmox-cluster][manual][software] LXC 134 has no log retention configured [single-node][production][maintenance]",
"Software",
"Maintenance",
"Open",
4,
None,
3,
96,
"Log volume grows without bound. Add retention and a compactor.",
),
(
"[backup-01][manual][software] Verify weekly offsite backup restore [single-node][production][task]",
"General",
"Task",
"In Progress",
4,
4,
1,
40,
"Restore one guest from the weekly backup and confirm it boots.",
),
(
"[switch-01][manual][network] Move camera VLAN to dedicated port group [single-node][testing][configuration]",
"Network",
"Task",
"Pending",
5,
3,
3,
120,
"Needs a maintenance window.",
),
(
"[node-03][auto][hardware] Fan 2 speed below threshold [single-node][production][problem]",
"Hardware",
"Problem",
"Closed",
3,
1,
5,
310,
"Fan replaced.",
),
(
"[proxmox-cluster][manual][software] Document UPS shutdown order [cluster-wide][production][task]",
"General",
"Task",
"Closed",
4,
2,
2,
400,
"Documented in the wiki.",
),
(
"[node-02][auto][hardware] Drive SN-1B77E4 temperature above 55C [single-node][production][problem]",
"Hardware",
"Problem",
"Open",
4,
None,
5,
6,
"Drive temperature 57C, threshold 55C.",
),
(
"[proxmox-cluster][manual][security] Review SSH access for decommissioned hosts [cluster-wide][production][task]",
"Security",
"Task",
"Pending",
3,
1,
1,
88,
"Remove stale authorized_keys entries.",
),
(
"[monitor-01][manual][software] Grafana alert rules need review [single-node][production][maintenance]",
"Software",
"Maintenance",
"Open",
4,
4,
1,
60,
"Several rules have no contact point.",
),
]
tids = []
for i, t in enumerate(T):
tid = str(100000000 + random.randint(1000000, 899999999))
tids.append(tid)
title, cat, typ, status, pr, asg, cr, age, desc = t
closed = ts(max(age - 5, 1)) if status == "Closed" else "NULL"
sql.append(
f"INSERT INTO tickets(ticket_id,title,category,type,status,description,created_at,updated_at,closed_at,priority,hash,created_by,updated_by,assigned_to) VALUES({q(tid)},{q(title)},{q(cat)},{q(typ)},{q(status)},{q(desc)},{ts(age)},{ts(max(age-3,1))},{closed},{pr},{q(hashlib.sha256(title.encode()).hexdigest())},{cr},{cr},{asg if asg else 'NULL'});"
)
sql.append(
f"INSERT INTO audit_log(user_id,action_type,entity_type,entity_id,details,created_at) VALUES({cr},'create','ticket',{q(tid)},{q(json.dumps({'title':title}))},{ts(age)});"
)
if asg:
sql.append(
f"INSERT INTO audit_log(user_id,action_type,entity_type,entity_id,details,created_at) VALUES(1,'assign','ticket',{q(tid)},{q(json.dumps({'assigned_to':{'from':'','to':users[asg-1][2]}}))},{ts(max(age-1,1))});"
)
if status != "Open":
sql.append(
f"INSERT INTO audit_log(user_id,action_type,entity_type,entity_id,details,created_at) VALUES({asg or 1},'status_change','ticket',{q(tid)},{q(json.dumps({'status':{'from':'Open','to':status}}))},{ts(max(age-2,1))});"
)
C = {
0: [
(2, "Confirmed on the node. Pulling the drive from the pool tonight and ordering a replacement.", 2),
(1, "Approved. Use the spare from the shelf, then reorder.", 1),
],
2: [
(
1,
"Slow ops are isolated to osd.9. `ceph tell osd.9 dump_historic_slow_ops` shows long waits on the DB device.",
20,
),
(2, "Marked the OSD out for now. Recovery is progressing.", 12),
],
4: [(1, "Failover worked as designed. Swapping the SFP+ module first.", 1)],
5: [(2, "Waiting for the Ceph warning to clear before starting the first node.", 30)],
6: [(4, "DNS challenge fails because the API token expired. Generated a new one.", 8)],
}
for idx, cs in C.items():
for uid, txt, age in cs:
sql.append(
f"INSERT INTO ticket_comments(ticket_id,user_name,comment_text,created_at,markdown_enabled,user_id) VALUES({q(tids[idx])},{q(users[uid-1][2])},{q(txt)},{ts(age)},1,{uid});"
)
sql.append(
f"INSERT INTO audit_log(user_id,action_type,entity_type,entity_id,details,created_at) VALUES({uid},'comment','comment','0',{q(json.dumps({'ticket_id':tids[idx]}))},{ts(age)});"
)
sql.append(
f"INSERT INTO ticket_dependencies(ticket_id,depends_on_id,dependency_type,created_by) VALUES({q(tids[5])},{q(tids[2])},'blocked_by',1);"
)
sql.append(
f"INSERT INTO ticket_dependencies(ticket_id,depends_on_id,dependency_type,created_by) VALUES({q(tids[0])},{q(tids[8])},'relates_to',1);"
)
sql.append(
f"INSERT INTO ticket_dependencies(ticket_id,depends_on_id,dependency_type,created_by) VALUES({q(tids[4])},{q(tids[14])},'relates_to',1);"
)
for tid in (tids[0], tids[2], tids[4]):
sql.append(f"INSERT INTO ticket_watchers(ticket_id,user_id) VALUES({q(tid)},1);")
for n, p, s in (
("hwmonDaemon (prod)", "hwm_9f3a", "read_write"),
("gandalf", "gnd_71c2", "read_write"),
("readonly-dashboard", "ro_55de", "read"),
):
sql.append(
f"INSERT INTO api_keys(key_name,key_hash,key_prefix,scope,created_by,last_used) VALUES({q(n)},{q(hashlib.sha256(p.encode()).hexdigest())},{q(p)},{q(s)},1,{ts(2)});"
)
sql.append(
f"INSERT INTO recurring_tickets(title_template,description_template,category,type,priority,schedule_type,schedule_day,next_run_at,created_by) VALUES('[proxmox-cluster][auto][software] Monthly patch review [cluster-wide][production][maintenance]','Review pending updates and schedule a window.','Software','Maintenance',4,'monthly',1,{ts(-200)},1);"
)
sql.append(
f"INSERT INTO recurring_tickets(title_template,description_template,category,type,priority,schedule_type,schedule_day,next_run_at,created_by) VALUES('[backup-01][auto][software] Weekly restore test [single-node][production][task]','Restore one guest and verify.','General','Task',4,'weekly',1,{ts(-60)},1);"
)
open(D + "/seed.sql", "w").write("\n".join(sql))
print(len(T), "tickets; ids sample", tids[:3])
+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>