Compare commits
2 Commits
dev
...
217-homepa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f88924d9b | ||
|
|
478ca5801b |
@ -1,53 +0,0 @@
|
|||||||
---
|
|
||||||
name: speckit-git-commit
|
|
||||||
description: Auto-commit changes after a Spec Kit command completes
|
|
||||||
compatibility: Requires spec-kit project structure with .specify/ directory
|
|
||||||
metadata:
|
|
||||||
author: github-spec-kit
|
|
||||||
source: git:commands/speckit.git.commit.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# Auto-Commit Changes
|
|
||||||
|
|
||||||
Automatically stage and commit all changes after a Spec Kit command completes.
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
This command is invoked as a hook after (or before) core commands. It:
|
|
||||||
|
|
||||||
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
|
|
||||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
|
||||||
3. Looks up the specific event key to see if auto-commit is enabled
|
|
||||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
|
||||||
5. Uses the per-command `message` if configured, otherwise a default message
|
|
||||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Determine the event name from the hook that triggered this command, then run the script:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
|
||||||
|
|
||||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
In `.specify/extensions/git/git-config.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
auto_commit:
|
|
||||||
default: false # Global toggle — set true to enable for all commands
|
|
||||||
after_specify:
|
|
||||||
enabled: true # Override per-command
|
|
||||||
message: "[Spec Kit] Add specification"
|
|
||||||
after_plan:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add implementation plan"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
|
||||||
- If no config file exists: skips (disabled by default)
|
|
||||||
- If no changes to commit: skips with a message
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
---
|
|
||||||
name: speckit-git-feature
|
|
||||||
description: Create a feature branch with sequential or timestamp numbering
|
|
||||||
compatibility: Requires spec-kit project structure with .specify/ directory
|
|
||||||
metadata:
|
|
||||||
author: github-spec-kit
|
|
||||||
source: git:commands/speckit.git.feature.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# Create Feature Branch
|
|
||||||
|
|
||||||
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
|
|
||||||
|
|
||||||
## User Input
|
|
||||||
|
|
||||||
```text
|
|
||||||
$ARGUMENTS
|
|
||||||
```
|
|
||||||
|
|
||||||
You **MUST** consider the user input before proceeding (if not empty).
|
|
||||||
|
|
||||||
## Environment Variable Override
|
|
||||||
|
|
||||||
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
|
||||||
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
|
||||||
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
|
||||||
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, warn the user and skip branch creation
|
|
||||||
|
|
||||||
## Branch Numbering Mode
|
|
||||||
|
|
||||||
Determine the branch numbering strategy by checking configuration in this order:
|
|
||||||
|
|
||||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
|
||||||
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
|
||||||
3. Default to `sequential` if neither exists
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Generate a concise short name (2-4 words) for the branch:
|
|
||||||
- Analyze the feature description and extract the most meaningful keywords
|
|
||||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
|
||||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
|
||||||
|
|
||||||
Run the appropriate script based on your platform:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
|
||||||
|
|
||||||
**IMPORTANT**:
|
|
||||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
|
||||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
|
||||||
- You must only ever run this script once per feature
|
|
||||||
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the current directory is not a Git repository:
|
|
||||||
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
|
|
||||||
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
The script outputs JSON with:
|
|
||||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
|
||||||
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
---
|
|
||||||
name: speckit-git-initialize
|
|
||||||
description: Initialize a Git repository with an initial commit
|
|
||||||
compatibility: Requires spec-kit project structure with .specify/ directory
|
|
||||||
metadata:
|
|
||||||
author: github-spec-kit
|
|
||||||
source: git:commands/speckit.git.initialize.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# Initialize Git Repository
|
|
||||||
|
|
||||||
Initialize a Git repository in the current project directory if one does not already exist.
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Run the appropriate script from the project root:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
|
|
||||||
|
|
||||||
If the extension scripts are not found, fall back to:
|
|
||||||
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
|
|
||||||
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
|
|
||||||
|
|
||||||
The script handles all checks internally:
|
|
||||||
- Skips if Git is not available
|
|
||||||
- Skips if already inside a Git repository
|
|
||||||
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
Replace the script to add project-specific Git initialization steps:
|
|
||||||
- Custom `.gitignore` templates
|
|
||||||
- Default branch naming (`git config init.defaultBranch`)
|
|
||||||
- Git LFS setup
|
|
||||||
- Git hooks installation
|
|
||||||
- Commit signing configuration
|
|
||||||
- Git Flow initialization
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
On success:
|
|
||||||
- `✓ Git repository initialized`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed:
|
|
||||||
- Warn the user
|
|
||||||
- Skip repository initialization
|
|
||||||
- The project continues to function without Git (specs can still be created under `specs/`)
|
|
||||||
|
|
||||||
If Git is installed but `git init`, `git add .`, or `git commit` fails:
|
|
||||||
- Surface the error to the user
|
|
||||||
- Stop this command rather than continuing with a partially initialized repository
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
---
|
|
||||||
name: speckit-git-remote
|
|
||||||
description: Detect Git remote URL for GitHub integration
|
|
||||||
compatibility: Requires spec-kit project structure with .specify/ directory
|
|
||||||
metadata:
|
|
||||||
author: github-spec-kit
|
|
||||||
source: git:commands/speckit.git.remote.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# Detect Git Remote URL
|
|
||||||
|
|
||||||
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and return empty:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; cannot determine remote URL
|
|
||||||
```
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Run the following command to get the remote URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git config --get remote.origin.url
|
|
||||||
```
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
Parse the remote URL and determine:
|
|
||||||
|
|
||||||
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
|
||||||
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
|
||||||
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
|
||||||
|
|
||||||
Supported URL formats:
|
|
||||||
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
|
||||||
- SSH: `git@github.com:<owner>/<repo>.git`
|
|
||||||
|
|
||||||
> [!CAUTION]
|
|
||||||
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
|
||||||
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
|
||||||
- Return an empty result
|
|
||||||
- Do NOT error — other workflows should continue without Git remote information
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
---
|
|
||||||
name: speckit-git-validate
|
|
||||||
description: Validate current branch follows feature branch naming conventions
|
|
||||||
compatibility: Requires spec-kit project structure with .specify/ directory
|
|
||||||
metadata:
|
|
||||||
author: github-spec-kit
|
|
||||||
source: git:commands/speckit.git.validate.md
|
|
||||||
---
|
|
||||||
|
|
||||||
# Validate Feature Branch
|
|
||||||
|
|
||||||
Validate that the current Git branch follows the expected feature branch naming conventions.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and skip validation:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; skipped branch validation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation Rules
|
|
||||||
|
|
||||||
Get the current branch name:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git rev-parse --abbrev-ref HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
The branch name must match one of these patterns:
|
|
||||||
|
|
||||||
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
|
|
||||||
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
If on a feature branch (matches either pattern):
|
|
||||||
- Output: `✓ On feature branch: <branch-name>`
|
|
||||||
- Check if the corresponding spec directory exists under `specs/`:
|
|
||||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
|
|
||||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
|
||||||
- If spec directory exists: `✓ Spec directory found: <path>`
|
|
||||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
|
||||||
|
|
||||||
If NOT on a feature branch:
|
|
||||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
|
||||||
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the directory is not a Git repository:
|
|
||||||
- Check the `SPECIFY_FEATURE` environment variable as a fallback
|
|
||||||
- If set, validate that value against the naming patterns
|
|
||||||
- If not set, skip validation with a warning
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
description = "Auto-commit changes after a Spec Kit command completes"
|
|
||||||
|
|
||||||
# Source: git
|
|
||||||
|
|
||||||
prompt = """
|
|
||||||
# Auto-Commit Changes
|
|
||||||
|
|
||||||
Automatically stage and commit all changes after a Spec Kit command completes.
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
This command is invoked as a hook after (or before) core commands. It:
|
|
||||||
|
|
||||||
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
|
|
||||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
|
||||||
3. Looks up the specific event key to see if auto-commit is enabled
|
|
||||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
|
||||||
5. Uses the per-command `message` if configured, otherwise a default message
|
|
||||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Determine the event name from the hook that triggered this command, then run the script:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
|
||||||
|
|
||||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
In `.specify/extensions/git/git-config.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
auto_commit:
|
|
||||||
default: false # Global toggle — set true to enable for all commands
|
|
||||||
after_specify:
|
|
||||||
enabled: true # Override per-command
|
|
||||||
message: "[Spec Kit] Add specification"
|
|
||||||
after_plan:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add implementation plan"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
|
||||||
- If no config file exists: skips (disabled by default)
|
|
||||||
- If no changes to commit: skips with a message
|
|
||||||
"""
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
description = "Create a feature branch with sequential or timestamp numbering"
|
|
||||||
|
|
||||||
# Source: git
|
|
||||||
|
|
||||||
prompt = """
|
|
||||||
# Create Feature Branch
|
|
||||||
|
|
||||||
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
|
|
||||||
|
|
||||||
## User Input
|
|
||||||
|
|
||||||
```text
|
|
||||||
{{args}}
|
|
||||||
```
|
|
||||||
|
|
||||||
You **MUST** consider the user input before proceeding (if not empty).
|
|
||||||
|
|
||||||
## Environment Variable Override
|
|
||||||
|
|
||||||
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
|
||||||
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
|
||||||
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
|
||||||
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, warn the user and skip branch creation
|
|
||||||
|
|
||||||
## Branch Numbering Mode
|
|
||||||
|
|
||||||
Determine the branch numbering strategy by checking configuration in this order:
|
|
||||||
|
|
||||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
|
||||||
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
|
||||||
3. Default to `sequential` if neither exists
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Generate a concise short name (2-4 words) for the branch:
|
|
||||||
- Analyze the feature description and extract the most meaningful keywords
|
|
||||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
|
||||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
|
||||||
|
|
||||||
Run the appropriate script based on your platform:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
|
||||||
|
|
||||||
**IMPORTANT**:
|
|
||||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
|
||||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
|
||||||
- You must only ever run this script once per feature
|
|
||||||
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the current directory is not a Git repository:
|
|
||||||
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
|
|
||||||
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
The script outputs JSON with:
|
|
||||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
|
||||||
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
|
||||||
"""
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
description = "Initialize a Git repository with an initial commit"
|
|
||||||
|
|
||||||
# Source: git
|
|
||||||
|
|
||||||
prompt = """
|
|
||||||
# Initialize Git Repository
|
|
||||||
|
|
||||||
Initialize a Git repository in the current project directory if one does not already exist.
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Run the appropriate script from the project root:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
|
|
||||||
|
|
||||||
If the extension scripts are not found, fall back to:
|
|
||||||
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
|
|
||||||
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
|
|
||||||
|
|
||||||
The script handles all checks internally:
|
|
||||||
- Skips if Git is not available
|
|
||||||
- Skips if already inside a Git repository
|
|
||||||
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
Replace the script to add project-specific Git initialization steps:
|
|
||||||
- Custom `.gitignore` templates
|
|
||||||
- Default branch naming (`git config init.defaultBranch`)
|
|
||||||
- Git LFS setup
|
|
||||||
- Git hooks installation
|
|
||||||
- Commit signing configuration
|
|
||||||
- Git Flow initialization
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
On success:
|
|
||||||
- `✓ Git repository initialized`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed:
|
|
||||||
- Warn the user
|
|
||||||
- Skip repository initialization
|
|
||||||
- The project continues to function without Git (specs can still be created under `specs/`)
|
|
||||||
|
|
||||||
If Git is installed but `git init`, `git add .`, or `git commit` fails:
|
|
||||||
- Surface the error to the user
|
|
||||||
- Stop this command rather than continuing with a partially initialized repository
|
|
||||||
"""
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
description = "Detect Git remote URL for GitHub integration"
|
|
||||||
|
|
||||||
# Source: git
|
|
||||||
|
|
||||||
prompt = """
|
|
||||||
# Detect Git Remote URL
|
|
||||||
|
|
||||||
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and return empty:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; cannot determine remote URL
|
|
||||||
```
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Run the following command to get the remote URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git config --get remote.origin.url
|
|
||||||
```
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
Parse the remote URL and determine:
|
|
||||||
|
|
||||||
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
|
||||||
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
|
||||||
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
|
||||||
|
|
||||||
Supported URL formats:
|
|
||||||
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
|
||||||
- SSH: `git@github.com:<owner>/<repo>.git`
|
|
||||||
|
|
||||||
> [!CAUTION]
|
|
||||||
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
|
||||||
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
|
||||||
- Return an empty result
|
|
||||||
- Do NOT error — other workflows should continue without Git remote information
|
|
||||||
"""
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
description = "Validate current branch follows feature branch naming conventions"
|
|
||||||
|
|
||||||
# Source: git
|
|
||||||
|
|
||||||
prompt = """
|
|
||||||
# Validate Feature Branch
|
|
||||||
|
|
||||||
Validate that the current Git branch follows the expected feature branch naming conventions.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and skip validation:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; skipped branch validation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation Rules
|
|
||||||
|
|
||||||
Get the current branch name:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git rev-parse --abbrev-ref HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
The branch name must match one of these patterns:
|
|
||||||
|
|
||||||
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
|
|
||||||
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
If on a feature branch (matches either pattern):
|
|
||||||
- Output: `✓ On feature branch: <branch-name>`
|
|
||||||
- Check if the corresponding spec directory exists under `specs/`:
|
|
||||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
|
|
||||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
|
||||||
- If spec directory exists: `✓ Spec directory found: <path>`
|
|
||||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
|
||||||
|
|
||||||
If NOT on a feature branch:
|
|
||||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
|
||||||
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the directory is not a Git repository:
|
|
||||||
- Check the `SPECIFY_FEATURE` environment variable as a fallback
|
|
||||||
- If set, validate that value against the naming patterns
|
|
||||||
- If not set, skip validation with a warning
|
|
||||||
"""
|
|
||||||
21
.github/agents/copilot-instructions.md
vendored
21
.github/agents/copilot-instructions.md
vendored
@ -228,16 +228,6 @@ ## Active Technologies
|
|||||||
- PostgreSQL via existing `operation_runs` plus related `baseline_snapshots`, `evidence_snapshots`, `tenant_reviews`, and `review_packs`; no schema changes planned (220-governance-run-summaries)
|
- PostgreSQL via existing `operation_runs` plus related `baseline_snapshots`, `evidence_snapshots`, `tenant_reviews`, and `review_packs`; no schema changes planned (220-governance-run-summaries)
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext` (221-findings-operator-inbox)
|
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext` (221-findings-operator-inbox)
|
||||||
- PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, and workspace context session state; no schema changes planned (221-findings-operator-inbox)
|
- PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, and workspace context session state; no schema changes planned (221-findings-operator-inbox)
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin pages/tables/actions/notifications, `Finding`, `FindingResource`, `FindingWorkflowService`, `FindingPolicy`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `WorkspaceContext`, and `UiEnforcement` (222-findings-intake-team-queue)
|
|
||||||
- PostgreSQL via existing `findings`, `tenants`, `tenant_memberships`, `audit_logs`, and workspace session context; no schema changes planned (222-findings-intake-team-queue)
|
|
||||||
- TypeScript 5.9, Astro 6, Node.js 20+ + Astro, astro-icon, Tailwind CSS v4, Playwright 1.59 (223-astrodeck-website-rebuild)
|
|
||||||
- File-based route files, Astro content collections under `src/content`, public assets, and planning documents under `specs/223-astrodeck-website-rebuild`; no database (223-astrodeck-website-rebuild)
|
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Laravel notifications (`database` channel), Filament database notifications, `Finding`, `FindingWorkflowService`, `FindingSlaPolicy`, `AlertRule`, `AlertDelivery`, `AlertDispatchService`, `EvaluateAlertsJob`, `CapabilityResolver`, `WorkspaceContext`, `TenantMembership`, `FindingResource` (224-findings-notifications-escalation)
|
|
||||||
- PostgreSQL via existing `findings`, `alert_rules`, `alert_deliveries`, `notifications`, `tenant_memberships`, and `audit_logs`; no schema changes planned (224-findings-notifications-escalation)
|
|
||||||
- PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `Finding`, `FindingResource`, `MyFindingsInbox`, `FindingsIntakeQueue`, `WorkspaceOverviewBuilder`, `EnsureFilamentTenantSelected`, `FindingWorkflowService`, `AuditLog`, `TenantMembership`, Filament page and table primitives (225-assignment-hygiene)
|
|
||||||
- PostgreSQL via existing `findings`, `audit_logs`, `tenant_memberships`, and `users`; no schema changes planned (225-assignment-hygiene)
|
|
||||||
- Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`) (226-astrodeck-inventory-planning)
|
|
||||||
- Filesystem only (`specs/226-astrodeck-inventory-planning/*`) (226-astrodeck-inventory-planning)
|
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -272,10 +262,13 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
- 226-astrodeck-inventory-planning: Added Markdown artifacts + Astro 6.0.0 + TypeScript 5.9 context for source discovery + Repository spec workflow (`.specify`), Astro website source tree under `apps/website/src`, existing component taxonomy (`primitives`, `content`, `sections`, `layout`)
|
- 221-findings-operator-inbox: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin panel pages, `Finding`, `FindingResource`, `WorkspaceOverviewBuilder`, `WorkspaceContext`, `WorkspaceCapabilityResolver`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, and `CanonicalNavigationContext`
|
||||||
- 225-assignment-hygiene: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `Finding`, `FindingResource`, `MyFindingsInbox`, `FindingsIntakeQueue`, `WorkspaceOverviewBuilder`, `EnsureFilamentTenantSelected`, `FindingWorkflowService`, `AuditLog`, `TenantMembership`, Filament page and table primitives
|
- 220-governance-run-summaries: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament v5, Livewire v4, Pest v4, Laravel Sail, `TenantlessOperationRunViewer`, `OperationRunResource`, `ArtifactTruthPresenter`, `OperatorExplanationBuilder`, `ReasonPresenter`, `OperationUxPresenter`, `SummaryCountsNormalizer`, and the existing enterprise-detail builders
|
||||||
- 224-findings-notifications-escalation: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Laravel notifications (`database` channel), Filament database notifications, `Finding`, `FindingWorkflowService`, `FindingSlaPolicy`, `AlertRule`, `AlertDelivery`, `AlertDispatchService`, `EvaluateAlertsJob`, `CapabilityResolver`, `WorkspaceContext`, `TenantMembership`, `FindingResource`
|
- 219-finding-ownership-semantics: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4
|
||||||
- 222-findings-intake-team-queue: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Filament admin pages/tables/actions/notifications, `Finding`, `FindingResource`, `FindingWorkflowService`, `FindingPolicy`, `CapabilityResolver`, `CanonicalAdminTenantFilterState`, `CanonicalNavigationContext`, `WorkspaceContext`, and `UiEnforcement`
|
- 218-homepage-hero: Added Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, existing Astro content modules and section primitives, Playwright browser smoke tests
|
||||||
|
- 217-homepage-structure: Added Astro 6.0.0 templates + TypeScript 5.9.x + Astro 6, Tailwind CSS v4, local Astro layout/section primitives, Astro content collections, Playwright browser smoke tests
|
||||||
|
- 216-provider-dispatch-gate: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Actions, Livewire 4, Pest 4, `ProviderOperationStartGate`, `ProviderOperationRegistry`, `ProviderConnectionResolver`, `OperationRunService`, `ProviderNextStepsRegistry`, `ReasonPresenter`, `OperationUxPresenter`, `OperationRunLinks`
|
||||||
|
- 215-website-core-pages: Added Astro 6.0.0 templates + TypeScript 5.9 strict + Astro 6, Tailwind CSS v4 via `@tailwindcss/vite`, Astro content collections, local Astro layout/primitive/content helpers, Playwright smoke tests
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|
||||||
### Pre-production compatibility check
|
### Pre-production compatibility check
|
||||||
|
|||||||
51
.github/agents/speckit.git.commit.agent.md
vendored
51
.github/agents/speckit.git.commit.agent.md
vendored
@ -1,51 +0,0 @@
|
|||||||
---
|
|
||||||
description: Auto-commit changes after a Spec Kit command completes
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Extension: git -->
|
|
||||||
<!-- Config: .specify/extensions/git/ -->
|
|
||||||
# Auto-Commit Changes
|
|
||||||
|
|
||||||
Automatically stage and commit all changes after a Spec Kit command completes.
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
This command is invoked as a hook after (or before) core commands. It:
|
|
||||||
|
|
||||||
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
|
|
||||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
|
||||||
3. Looks up the specific event key to see if auto-commit is enabled
|
|
||||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
|
||||||
5. Uses the per-command `message` if configured, otherwise a default message
|
|
||||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Determine the event name from the hook that triggered this command, then run the script:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
|
||||||
|
|
||||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
In `.specify/extensions/git/git-config.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
auto_commit:
|
|
||||||
default: false # Global toggle — set true to enable for all commands
|
|
||||||
after_specify:
|
|
||||||
enabled: true # Override per-command
|
|
||||||
message: "[Spec Kit] Add specification"
|
|
||||||
after_plan:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add implementation plan"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
|
||||||
- If no config file exists: skips (disabled by default)
|
|
||||||
- If no changes to commit: skips with a message
|
|
||||||
70
.github/agents/speckit.git.feature.agent.md
vendored
70
.github/agents/speckit.git.feature.agent.md
vendored
@ -1,70 +0,0 @@
|
|||||||
---
|
|
||||||
description: Create a feature branch with sequential or timestamp numbering
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Extension: git -->
|
|
||||||
<!-- Config: .specify/extensions/git/ -->
|
|
||||||
# Create Feature Branch
|
|
||||||
|
|
||||||
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
|
|
||||||
|
|
||||||
## User Input
|
|
||||||
|
|
||||||
```text
|
|
||||||
$ARGUMENTS
|
|
||||||
```
|
|
||||||
|
|
||||||
You **MUST** consider the user input before proceeding (if not empty).
|
|
||||||
|
|
||||||
## Environment Variable Override
|
|
||||||
|
|
||||||
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
|
||||||
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
|
||||||
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
|
||||||
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, warn the user and skip branch creation
|
|
||||||
|
|
||||||
## Branch Numbering Mode
|
|
||||||
|
|
||||||
Determine the branch numbering strategy by checking configuration in this order:
|
|
||||||
|
|
||||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
|
||||||
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
|
||||||
3. Default to `sequential` if neither exists
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Generate a concise short name (2-4 words) for the branch:
|
|
||||||
- Analyze the feature description and extract the most meaningful keywords
|
|
||||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
|
||||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
|
||||||
|
|
||||||
Run the appropriate script based on your platform:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
|
||||||
|
|
||||||
**IMPORTANT**:
|
|
||||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
|
||||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
|
||||||
- You must only ever run this script once per feature
|
|
||||||
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the current directory is not a Git repository:
|
|
||||||
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
|
|
||||||
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
The script outputs JSON with:
|
|
||||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
|
||||||
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
|
||||||
52
.github/agents/speckit.git.initialize.agent.md
vendored
52
.github/agents/speckit.git.initialize.agent.md
vendored
@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
description: Initialize a Git repository with an initial commit
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Extension: git -->
|
|
||||||
<!-- Config: .specify/extensions/git/ -->
|
|
||||||
# Initialize Git Repository
|
|
||||||
|
|
||||||
Initialize a Git repository in the current project directory if one does not already exist.
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Run the appropriate script from the project root:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
|
|
||||||
|
|
||||||
If the extension scripts are not found, fall back to:
|
|
||||||
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
|
|
||||||
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
|
|
||||||
|
|
||||||
The script handles all checks internally:
|
|
||||||
- Skips if Git is not available
|
|
||||||
- Skips if already inside a Git repository
|
|
||||||
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
Replace the script to add project-specific Git initialization steps:
|
|
||||||
- Custom `.gitignore` templates
|
|
||||||
- Default branch naming (`git config init.defaultBranch`)
|
|
||||||
- Git LFS setup
|
|
||||||
- Git hooks installation
|
|
||||||
- Commit signing configuration
|
|
||||||
- Git Flow initialization
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
On success:
|
|
||||||
- `✓ Git repository initialized`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed:
|
|
||||||
- Warn the user
|
|
||||||
- Skip repository initialization
|
|
||||||
- The project continues to function without Git (specs can still be created under `specs/`)
|
|
||||||
|
|
||||||
If Git is installed but `git init`, `git add .`, or `git commit` fails:
|
|
||||||
- Surface the error to the user
|
|
||||||
- Stop this command rather than continuing with a partially initialized repository
|
|
||||||
48
.github/agents/speckit.git.remote.agent.md
vendored
48
.github/agents/speckit.git.remote.agent.md
vendored
@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
description: Detect Git remote URL for GitHub integration
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Extension: git -->
|
|
||||||
<!-- Config: .specify/extensions/git/ -->
|
|
||||||
# Detect Git Remote URL
|
|
||||||
|
|
||||||
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and return empty:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; cannot determine remote URL
|
|
||||||
```
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Run the following command to get the remote URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git config --get remote.origin.url
|
|
||||||
```
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
Parse the remote URL and determine:
|
|
||||||
|
|
||||||
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
|
||||||
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
|
||||||
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
|
||||||
|
|
||||||
Supported URL formats:
|
|
||||||
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
|
||||||
- SSH: `git@github.com:<owner>/<repo>.git`
|
|
||||||
|
|
||||||
> [!CAUTION]
|
|
||||||
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
|
||||||
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
|
||||||
- Return an empty result
|
|
||||||
- Do NOT error — other workflows should continue without Git remote information
|
|
||||||
52
.github/agents/speckit.git.validate.agent.md
vendored
52
.github/agents/speckit.git.validate.agent.md
vendored
@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
description: Validate current branch follows feature branch naming conventions
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Extension: git -->
|
|
||||||
<!-- Config: .specify/extensions/git/ -->
|
|
||||||
# Validate Feature Branch
|
|
||||||
|
|
||||||
Validate that the current Git branch follows the expected feature branch naming conventions.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and skip validation:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; skipped branch validation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation Rules
|
|
||||||
|
|
||||||
Get the current branch name:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git rev-parse --abbrev-ref HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
The branch name must match one of these patterns:
|
|
||||||
|
|
||||||
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
|
|
||||||
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
If on a feature branch (matches either pattern):
|
|
||||||
- Output: `✓ On feature branch: <branch-name>`
|
|
||||||
- Check if the corresponding spec directory exists under `specs/`:
|
|
||||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
|
|
||||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
|
||||||
- If spec directory exists: `✓ Spec directory found: <path>`
|
|
||||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
|
||||||
|
|
||||||
If NOT on a feature branch:
|
|
||||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
|
||||||
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the directory is not a Git repository:
|
|
||||||
- Check the `SPECIFY_FEATURE` environment variable as a fallback
|
|
||||||
- If set, validate that value against the naming patterns
|
|
||||||
- If not set, skip validation with a warning
|
|
||||||
5
.github/copilot-instructions.md
vendored
5
.github/copilot-instructions.md
vendored
@ -673,8 +673,3 @@ ### Replaced Utilities
|
|||||||
| decoration-slice | box-decoration-slice |
|
| decoration-slice | box-decoration-slice |
|
||||||
| decoration-clone | box-decoration-clone |
|
| decoration-clone | box-decoration-clone |
|
||||||
</laravel-boost-guidelines>
|
</laravel-boost-guidelines>
|
||||||
|
|
||||||
<!-- SPECKIT START -->
|
|
||||||
For additional context about technologies to be used, project structure,
|
|
||||||
shell commands, and other important information, read the current plan
|
|
||||||
<!-- SPECKIT END -->
|
|
||||||
|
|||||||
3
.github/prompts/speckit.git.commit.prompt.md
vendored
3
.github/prompts/speckit.git.commit.prompt.md
vendored
@ -1,3 +0,0 @@
|
|||||||
---
|
|
||||||
agent: speckit.git.commit
|
|
||||||
---
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
---
|
|
||||||
agent: speckit.git.feature
|
|
||||||
---
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
---
|
|
||||||
agent: speckit.git.initialize
|
|
||||||
---
|
|
||||||
3
.github/prompts/speckit.git.remote.prompt.md
vendored
3
.github/prompts/speckit.git.remote.prompt.md
vendored
@ -1,3 +0,0 @@
|
|||||||
---
|
|
||||||
agent: speckit.git.remote
|
|
||||||
---
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
---
|
|
||||||
agent: speckit.git.validate
|
|
||||||
---
|
|
||||||
295
.github/skills/browsertest/SKILL.md
vendored
295
.github/skills/browsertest/SKILL.md
vendored
@ -1,295 +0,0 @@
|
|||||||
---
|
|
||||||
name: browsertest
|
|
||||||
description: Führe einen vollständigen Smoke-Browser-Test im Integrated Browser für das aktuelle Feature aus, inklusive Happy Path, zentraler Regressionen, Kontext-Prüfung und belastbarer Ergebniszusammenfassung.
|
|
||||||
license: MIT
|
|
||||||
metadata:
|
|
||||||
author: GitHub Copilot
|
|
||||||
---
|
|
||||||
|
|
||||||
# Browser Smoke Test
|
|
||||||
|
|
||||||
## What This Skill Does
|
|
||||||
|
|
||||||
Use this skill to validate the current feature end-to-end in the integrated browser.
|
|
||||||
|
|
||||||
This is a focused smoke test, not a full exploratory test session. The goal is to prove that the primary operator flow:
|
|
||||||
|
|
||||||
- loads in the correct auth, workspace, and tenant context
|
|
||||||
- exposes the expected controls and decision points
|
|
||||||
- completes the main happy path without blocking issues
|
|
||||||
- lands in the expected end state or canonical drilldown
|
|
||||||
- does not show obvious regressions such as broken navigation, missing data, or conflicting actions
|
|
||||||
|
|
||||||
The skill should produce a concrete pass or fail result with actionable evidence.
|
|
||||||
|
|
||||||
## When To Apply
|
|
||||||
|
|
||||||
Activate this skill when:
|
|
||||||
|
|
||||||
- the user asks to smoke test the current feature in the browser
|
|
||||||
- a new Filament page, dashboard signal, report, wizard, or detail flow was just added
|
|
||||||
- a UI regression fix needs confirmation in a real browser context
|
|
||||||
- the primary question is whether the feature works from an operator perspective
|
|
||||||
- you need a quick integration-level check without writing a full browser test suite first
|
|
||||||
|
|
||||||
## What Success Looks Like
|
|
||||||
|
|
||||||
A successful smoke test confirms all of the following:
|
|
||||||
|
|
||||||
- the target route opens successfully
|
|
||||||
- the visible context is correct
|
|
||||||
- the main flow is usable
|
|
||||||
- the expected result appears after interaction
|
|
||||||
- the route or drilldown destination is correct
|
|
||||||
- the surface does not obviously violate its intended interaction model
|
|
||||||
|
|
||||||
If the test cannot be completed, the output must clearly state whether the blocker is:
|
|
||||||
|
|
||||||
- authentication
|
|
||||||
- missing data or fixture state
|
|
||||||
- routing
|
|
||||||
- UI interaction failure
|
|
||||||
- server error
|
|
||||||
- an unclear expected behavior contract
|
|
||||||
|
|
||||||
Do not guess. If the route or state is blocked, report the blocker explicitly.
|
|
||||||
|
|
||||||
## Preconditions
|
|
||||||
|
|
||||||
Before running the browser smoke test, make sure you know:
|
|
||||||
|
|
||||||
- the canonical route or entry point for the feature
|
|
||||||
- the primary operator action or happy path
|
|
||||||
- the expected success state
|
|
||||||
- whether the feature depends on a specific tenant, workspace, or seeded record
|
|
||||||
|
|
||||||
When available, use the feature spec, quickstart, tasks, or current browser page as the source of truth.
|
|
||||||
|
|
||||||
## Standard Workflow
|
|
||||||
|
|
||||||
### 1. Define the smoke-test scope
|
|
||||||
|
|
||||||
Identify:
|
|
||||||
|
|
||||||
- the route to open
|
|
||||||
- the primary action to perform
|
|
||||||
- the expected end state
|
|
||||||
- one or two critical regressions that must not break
|
|
||||||
|
|
||||||
The smoke test should stay narrow. Prefer one complete happy path plus one critical boundary over broad exploratory clicking.
|
|
||||||
|
|
||||||
### 2. Establish the browser state
|
|
||||||
|
|
||||||
- Reuse the current browser page if it already matches the target feature.
|
|
||||||
- Otherwise open the canonical route.
|
|
||||||
- Confirm the current auth and scope context before interacting.
|
|
||||||
|
|
||||||
For this repo, that usually means checking whether the page is on:
|
|
||||||
|
|
||||||
- `/admin/...` for workspace-context surfaces
|
|
||||||
- `/admin/t/{tenant}/...` for tenant-context surfaces
|
|
||||||
|
|
||||||
### 3. Inspect before acting
|
|
||||||
|
|
||||||
- Use `read_page` before interacting so you understand the live controls, refs, headings, and route context.
|
|
||||||
- Prefer `read_page` over screenshots for actual interaction planning.
|
|
||||||
- Use screenshots only for visual evidence or when the user asks for them.
|
|
||||||
|
|
||||||
### 4. Execute the primary happy path
|
|
||||||
|
|
||||||
Run the smallest meaningful flow that proves the feature works.
|
|
||||||
|
|
||||||
Typical steps include:
|
|
||||||
|
|
||||||
- open the page
|
|
||||||
- verify heading or key summary text
|
|
||||||
- click the primary CTA or row
|
|
||||||
- fill the minimum required form fields
|
|
||||||
- confirm modal or dialog text when relevant
|
|
||||||
- submit or navigate
|
|
||||||
- verify the expected destination or changed state
|
|
||||||
|
|
||||||
After each meaningful action, re-read the page so the next step is based on current DOM state.
|
|
||||||
|
|
||||||
### 5. Validate the outcome
|
|
||||||
|
|
||||||
Check the exact result that matters for the feature.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- a new row appears
|
|
||||||
- a status changes
|
|
||||||
- a success message appears
|
|
||||||
- a report filter changes the result set
|
|
||||||
- a row click lands on the canonical detail page
|
|
||||||
- a dashboard signal links to the correct report page
|
|
||||||
|
|
||||||
### 6. Check for obvious regressions
|
|
||||||
|
|
||||||
Even in a smoke test, verify a few core non-negotiables:
|
|
||||||
|
|
||||||
- the page is not blank or half-rendered
|
|
||||||
- the main action is present and usable
|
|
||||||
- the visible context is correct
|
|
||||||
- the drilldown destination is canonical
|
|
||||||
- no obviously duplicated primary actions exist
|
|
||||||
- no stuck modal, spinner, or blocked interaction remains onscreen
|
|
||||||
|
|
||||||
### 7. Capture evidence and summarize clearly
|
|
||||||
|
|
||||||
Your result should state:
|
|
||||||
|
|
||||||
- route tested
|
|
||||||
- context used
|
|
||||||
- steps executed
|
|
||||||
- pass or fail
|
|
||||||
- exact blocker or discrepancy if failed
|
|
||||||
|
|
||||||
Include a screenshot only when it adds value.
|
|
||||||
|
|
||||||
## Tool Usage Guidance
|
|
||||||
|
|
||||||
Use the browser tools in this order by default:
|
|
||||||
|
|
||||||
1. `read_page`
|
|
||||||
2. `click_element`
|
|
||||||
3. `type_in_page`
|
|
||||||
4. `handle_dialog` when needed
|
|
||||||
5. `navigate_page` or `open_browser_page` only when route changes are required
|
|
||||||
6. `run_playwright_code` only if the normal browser tools are insufficient
|
|
||||||
7. `screenshot_page` for evidence, not for primary navigation logic
|
|
||||||
|
|
||||||
## Repo-Specific Guidance For TenantPilot
|
|
||||||
|
|
||||||
### Workspace surfaces
|
|
||||||
|
|
||||||
For `/admin` pages and similar workspace-context surfaces:
|
|
||||||
|
|
||||||
- verify the page is reachable without forcing tenant-route assumptions
|
|
||||||
- confirm any summary signal or CTA lands on the canonical destination
|
|
||||||
- verify calm-state versus attention-state behavior when the feature defines both
|
|
||||||
|
|
||||||
### Tenant surfaces
|
|
||||||
|
|
||||||
For `/admin/t/{tenant}/...` pages:
|
|
||||||
|
|
||||||
- verify the tenant context is explicit and correct
|
|
||||||
- verify drilldowns stay in the intended tenant scope
|
|
||||||
- treat cross-tenant leakage or silent scope changes as failures
|
|
||||||
|
|
||||||
### Filament list or report surfaces
|
|
||||||
|
|
||||||
For Filament tables, reports, or registry-style pages:
|
|
||||||
|
|
||||||
- verify the heading and table shell render
|
|
||||||
- verify fixed filters or summary controls exist when the spec requires them
|
|
||||||
- verify row click or the primary inspect affordance behaves as designed
|
|
||||||
- verify empty-state messaging is specific rather than generic when the feature defines custom behavior
|
|
||||||
|
|
||||||
### Filament detail pages
|
|
||||||
|
|
||||||
For detail or view surfaces:
|
|
||||||
|
|
||||||
- verify the canonical record loads
|
|
||||||
- verify expected sections or summary content are present
|
|
||||||
- verify critical actions or drillbacks are usable
|
|
||||||
|
|
||||||
## Result Format
|
|
||||||
|
|
||||||
Use a compact result format like this:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Browser smoke result: PASS
|
|
||||||
Route: /admin/findings/hygiene
|
|
||||||
Context: workspace member with visible hygiene issues
|
|
||||||
Steps: opened report -> verified filters -> clicked finding row -> landed on canonical finding detail
|
|
||||||
Verified: report rendered, primary interaction worked, drilldown route was correct
|
|
||||||
```
|
|
||||||
|
|
||||||
If the test fails:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Browser smoke result: FAIL
|
|
||||||
Route: /admin/findings/hygiene
|
|
||||||
Context: authenticated workspace member
|
|
||||||
Failed step: clicking the summary CTA
|
|
||||||
Expected: navigate to /admin/findings/hygiene
|
|
||||||
Actual: remained on /admin with no route change
|
|
||||||
Blocker: CTA appears rendered but is not interactive
|
|
||||||
```
|
|
||||||
|
|
||||||
## Examples
|
|
||||||
|
|
||||||
### Example 1: Smoke test a new report page
|
|
||||||
|
|
||||||
Use this when the feature adds a new read-only report.
|
|
||||||
|
|
||||||
Steps:
|
|
||||||
|
|
||||||
- open the canonical report route
|
|
||||||
- verify the page heading and main controls
|
|
||||||
- confirm the table or defined empty state is visible
|
|
||||||
- click one row or primary inspect affordance
|
|
||||||
- verify navigation lands on the canonical detail route
|
|
||||||
|
|
||||||
Pass criteria:
|
|
||||||
|
|
||||||
- report loads
|
|
||||||
- intended controls exist
|
|
||||||
- primary inspect path works
|
|
||||||
|
|
||||||
### Example 2: Smoke test a dashboard signal
|
|
||||||
|
|
||||||
Use this when the feature adds a summary signal on `/admin`.
|
|
||||||
|
|
||||||
Steps:
|
|
||||||
|
|
||||||
- open `/admin`
|
|
||||||
- find the signal
|
|
||||||
- verify the visible count or summary text
|
|
||||||
- click the CTA
|
|
||||||
- confirm navigation lands on the canonical downstream surface
|
|
||||||
|
|
||||||
Pass criteria:
|
|
||||||
|
|
||||||
- signal is visible in the correct state
|
|
||||||
- CTA text is present
|
|
||||||
- CTA opens the correct route
|
|
||||||
|
|
||||||
### Example 3: Smoke test a tenant detail follow-up
|
|
||||||
|
|
||||||
Use this when a workspace-level surface should drill into a tenant-level detail page.
|
|
||||||
|
|
||||||
Steps:
|
|
||||||
|
|
||||||
- open the workspace-level surface
|
|
||||||
- trigger the drilldown
|
|
||||||
- verify the target route includes the correct tenant and record
|
|
||||||
- confirm the target page actually loads the expected detail content
|
|
||||||
|
|
||||||
Pass criteria:
|
|
||||||
|
|
||||||
- drilldown route is canonical
|
|
||||||
- tenant context is correct
|
|
||||||
- destination content matches the selected record
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
- Clicking before reading the page state and refs
|
|
||||||
- Treating a blocked auth session as a feature failure
|
|
||||||
- Confusing workspace-context routes with tenant-context routes
|
|
||||||
- Reporting visual impressions without validating the actual interaction result
|
|
||||||
- Forgetting to re-read the page after a modal opens or a route changes
|
|
||||||
- Claiming success without verifying the final destination or changed state
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
This skill does not replace:
|
|
||||||
|
|
||||||
- full exploratory QA
|
|
||||||
- formal Pest browser coverage
|
|
||||||
- accessibility review
|
|
||||||
- visual regression approval
|
|
||||||
- backend correctness tests
|
|
||||||
|
|
||||||
It is a fast, real-browser confidence pass for the current feature.
|
|
||||||
@ -1,148 +0,0 @@
|
|||||||
installed: []
|
|
||||||
settings:
|
|
||||||
auto_execute_hooks: true
|
|
||||||
hooks:
|
|
||||||
before_constitution:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.initialize
|
|
||||||
enabled: true
|
|
||||||
optional: false
|
|
||||||
prompt: Execute speckit.git.initialize?
|
|
||||||
description: Initialize Git repository before constitution setup
|
|
||||||
condition: null
|
|
||||||
before_specify:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.feature
|
|
||||||
enabled: true
|
|
||||||
optional: false
|
|
||||||
prompt: Execute speckit.git.feature?
|
|
||||||
description: Create feature branch before specification
|
|
||||||
condition: null
|
|
||||||
before_clarify:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit outstanding changes before clarification?
|
|
||||||
description: Auto-commit before spec clarification
|
|
||||||
condition: null
|
|
||||||
before_plan:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit outstanding changes before planning?
|
|
||||||
description: Auto-commit before implementation planning
|
|
||||||
condition: null
|
|
||||||
before_tasks:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit outstanding changes before task generation?
|
|
||||||
description: Auto-commit before task generation
|
|
||||||
condition: null
|
|
||||||
before_implement:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit outstanding changes before implementation?
|
|
||||||
description: Auto-commit before implementation
|
|
||||||
condition: null
|
|
||||||
before_checklist:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit outstanding changes before checklist?
|
|
||||||
description: Auto-commit before checklist generation
|
|
||||||
condition: null
|
|
||||||
before_analyze:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit outstanding changes before analysis?
|
|
||||||
description: Auto-commit before analysis
|
|
||||||
condition: null
|
|
||||||
before_taskstoissues:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit outstanding changes before issue sync?
|
|
||||||
description: Auto-commit before tasks-to-issues conversion
|
|
||||||
condition: null
|
|
||||||
after_constitution:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit constitution changes?
|
|
||||||
description: Auto-commit after constitution update
|
|
||||||
condition: null
|
|
||||||
after_specify:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit specification changes?
|
|
||||||
description: Auto-commit after specification
|
|
||||||
condition: null
|
|
||||||
after_clarify:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit clarification changes?
|
|
||||||
description: Auto-commit after spec clarification
|
|
||||||
condition: null
|
|
||||||
after_plan:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit plan changes?
|
|
||||||
description: Auto-commit after implementation planning
|
|
||||||
condition: null
|
|
||||||
after_tasks:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit task changes?
|
|
||||||
description: Auto-commit after task generation
|
|
||||||
condition: null
|
|
||||||
after_implement:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit implementation changes?
|
|
||||||
description: Auto-commit after implementation
|
|
||||||
condition: null
|
|
||||||
after_checklist:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit checklist changes?
|
|
||||||
description: Auto-commit after checklist generation
|
|
||||||
condition: null
|
|
||||||
after_analyze:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit analysis results?
|
|
||||||
description: Auto-commit after analysis
|
|
||||||
condition: null
|
|
||||||
after_taskstoissues:
|
|
||||||
- extension: git
|
|
||||||
command: speckit.git.commit
|
|
||||||
enabled: true
|
|
||||||
optional: true
|
|
||||||
prompt: Commit after syncing issues?
|
|
||||||
description: Auto-commit after tasks-to-issues conversion
|
|
||||||
condition: null
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"extensions": {
|
|
||||||
"git": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"source": "local",
|
|
||||||
"manifest_hash": "sha256:9731aa8143a72fbebfdb440f155038ab42642517c2b2bdbbf67c8fdbe076ed79",
|
|
||||||
"enabled": true,
|
|
||||||
"priority": 10,
|
|
||||||
"registered_commands": {
|
|
||||||
"agy": [
|
|
||||||
"speckit.git.feature",
|
|
||||||
"speckit.git.validate",
|
|
||||||
"speckit.git.remote",
|
|
||||||
"speckit.git.initialize",
|
|
||||||
"speckit.git.commit"
|
|
||||||
],
|
|
||||||
"codex": [
|
|
||||||
"speckit.git.feature",
|
|
||||||
"speckit.git.validate",
|
|
||||||
"speckit.git.remote",
|
|
||||||
"speckit.git.initialize",
|
|
||||||
"speckit.git.commit"
|
|
||||||
],
|
|
||||||
"copilot": [
|
|
||||||
"speckit.git.feature",
|
|
||||||
"speckit.git.validate",
|
|
||||||
"speckit.git.remote",
|
|
||||||
"speckit.git.initialize",
|
|
||||||
"speckit.git.commit"
|
|
||||||
],
|
|
||||||
"gemini": [
|
|
||||||
"speckit.git.feature",
|
|
||||||
"speckit.git.validate",
|
|
||||||
"speckit.git.remote",
|
|
||||||
"speckit.git.initialize",
|
|
||||||
"speckit.git.commit"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"registered_skills": [],
|
|
||||||
"installed_at": "2026-04-22T21:58:03.029565+00:00"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
# Git Branching Workflow Extension
|
|
||||||
|
|
||||||
Git repository initialization, feature branch creation, numbering (sequential/timestamp), validation, remote detection, and auto-commit for Spec Kit.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This extension provides Git operations as an optional, self-contained module. It manages:
|
|
||||||
|
|
||||||
- **Repository initialization** with configurable commit messages
|
|
||||||
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
|
|
||||||
- **Branch validation** to ensure branches follow naming conventions
|
|
||||||
- **Git remote detection** for GitHub integration (e.g., issue creation)
|
|
||||||
- **Auto-commit** after core commands (configurable per-command with custom messages)
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
| Command | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `speckit.git.initialize` | Initialize a Git repository with a configurable commit message |
|
|
||||||
| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering |
|
|
||||||
| `speckit.git.validate` | Validate current branch follows feature branch naming conventions |
|
|
||||||
| `speckit.git.remote` | Detect Git remote URL for GitHub integration |
|
|
||||||
| `speckit.git.commit` | Auto-commit changes (configurable per-command enable/disable and messages) |
|
|
||||||
|
|
||||||
## Hooks
|
|
||||||
|
|
||||||
| Event | Command | Optional | Description |
|
|
||||||
|-------|---------|----------|-------------|
|
|
||||||
| `before_constitution` | `speckit.git.initialize` | No | Init git repo before constitution |
|
|
||||||
| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification |
|
|
||||||
| `before_clarify` | `speckit.git.commit` | Yes | Commit outstanding changes before clarification |
|
|
||||||
| `before_plan` | `speckit.git.commit` | Yes | Commit outstanding changes before planning |
|
|
||||||
| `before_tasks` | `speckit.git.commit` | Yes | Commit outstanding changes before task generation |
|
|
||||||
| `before_implement` | `speckit.git.commit` | Yes | Commit outstanding changes before implementation |
|
|
||||||
| `before_checklist` | `speckit.git.commit` | Yes | Commit outstanding changes before checklist |
|
|
||||||
| `before_analyze` | `speckit.git.commit` | Yes | Commit outstanding changes before analysis |
|
|
||||||
| `before_taskstoissues` | `speckit.git.commit` | Yes | Commit outstanding changes before issue sync |
|
|
||||||
| `after_constitution` | `speckit.git.commit` | Yes | Auto-commit after constitution update |
|
|
||||||
| `after_specify` | `speckit.git.commit` | Yes | Auto-commit after specification |
|
|
||||||
| `after_clarify` | `speckit.git.commit` | Yes | Auto-commit after clarification |
|
|
||||||
| `after_plan` | `speckit.git.commit` | Yes | Auto-commit after planning |
|
|
||||||
| `after_tasks` | `speckit.git.commit` | Yes | Auto-commit after task generation |
|
|
||||||
| `after_implement` | `speckit.git.commit` | Yes | Auto-commit after implementation |
|
|
||||||
| `after_checklist` | `speckit.git.commit` | Yes | Auto-commit after checklist |
|
|
||||||
| `after_analyze` | `speckit.git.commit` | Yes | Auto-commit after analysis |
|
|
||||||
| `after_taskstoissues` | `speckit.git.commit` | Yes | Auto-commit after issue sync |
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
Configuration is stored in `.specify/extensions/git/git-config.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# Branch numbering strategy: "sequential" or "timestamp"
|
|
||||||
branch_numbering: sequential
|
|
||||||
|
|
||||||
# Custom commit message for git init
|
|
||||||
init_commit_message: "[Spec Kit] Initial commit"
|
|
||||||
|
|
||||||
# Auto-commit per command (all disabled by default)
|
|
||||||
# Example: enable auto-commit after specify
|
|
||||||
auto_commit:
|
|
||||||
default: false
|
|
||||||
after_specify:
|
|
||||||
enabled: true
|
|
||||||
message: "[Spec Kit] Add specification"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install the bundled git extension (no network required)
|
|
||||||
specify extension add git
|
|
||||||
```
|
|
||||||
|
|
||||||
## Disabling
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Disable the git extension (spec creation continues without branching)
|
|
||||||
specify extension disable git
|
|
||||||
|
|
||||||
# Re-enable it
|
|
||||||
specify extension enable git
|
|
||||||
```
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
When Git is not installed or the directory is not a Git repository:
|
|
||||||
- Spec directories are still created under `specs/`
|
|
||||||
- Branch creation is skipped with a warning
|
|
||||||
- Branch validation is skipped with a warning
|
|
||||||
- Remote detection returns empty results
|
|
||||||
|
|
||||||
## Scripts
|
|
||||||
|
|
||||||
The extension bundles cross-platform scripts:
|
|
||||||
|
|
||||||
- `scripts/bash/create-new-feature.sh` — Bash implementation
|
|
||||||
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
|
|
||||||
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation
|
|
||||||
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
description: "Auto-commit changes after a Spec Kit command completes"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Auto-Commit Changes
|
|
||||||
|
|
||||||
Automatically stage and commit all changes after a Spec Kit command completes.
|
|
||||||
|
|
||||||
## Behavior
|
|
||||||
|
|
||||||
This command is invoked as a hook after (or before) core commands. It:
|
|
||||||
|
|
||||||
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
|
|
||||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
|
||||||
3. Looks up the specific event key to see if auto-commit is enabled
|
|
||||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
|
||||||
5. Uses the per-command `message` if configured, otherwise a default message
|
|
||||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Determine the event name from the hook that triggered this command, then run the script:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
|
||||||
|
|
||||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
In `.specify/extensions/git/git-config.yml`:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
auto_commit:
|
|
||||||
default: false # Global toggle — set true to enable for all commands
|
|
||||||
after_specify:
|
|
||||||
enabled: true # Override per-command
|
|
||||||
message: "[Spec Kit] Add specification"
|
|
||||||
after_plan:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add implementation plan"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
|
||||||
- If no config file exists: skips (disabled by default)
|
|
||||||
- If no changes to commit: skips with a message
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
---
|
|
||||||
description: "Create a feature branch with sequential or timestamp numbering"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Create Feature Branch
|
|
||||||
|
|
||||||
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
|
|
||||||
|
|
||||||
## User Input
|
|
||||||
|
|
||||||
```text
|
|
||||||
$ARGUMENTS
|
|
||||||
```
|
|
||||||
|
|
||||||
You **MUST** consider the user input before proceeding (if not empty).
|
|
||||||
|
|
||||||
## Environment Variable Override
|
|
||||||
|
|
||||||
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
|
||||||
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
|
||||||
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
|
||||||
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, warn the user and skip branch creation
|
|
||||||
|
|
||||||
## Branch Numbering Mode
|
|
||||||
|
|
||||||
Determine the branch numbering strategy by checking configuration in this order:
|
|
||||||
|
|
||||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
|
||||||
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
|
||||||
3. Default to `sequential` if neither exists
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Generate a concise short name (2-4 words) for the branch:
|
|
||||||
- Analyze the feature description and extract the most meaningful keywords
|
|
||||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
|
||||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
|
||||||
|
|
||||||
Run the appropriate script based on your platform:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
|
||||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
|
||||||
|
|
||||||
**IMPORTANT**:
|
|
||||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
|
||||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
|
||||||
- You must only ever run this script once per feature
|
|
||||||
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the current directory is not a Git repository:
|
|
||||||
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
|
|
||||||
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
The script outputs JSON with:
|
|
||||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
|
||||||
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
description: "Initialize a Git repository with an initial commit"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Initialize Git Repository
|
|
||||||
|
|
||||||
Initialize a Git repository in the current project directory if one does not already exist.
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Run the appropriate script from the project root:
|
|
||||||
|
|
||||||
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
|
|
||||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
|
|
||||||
|
|
||||||
If the extension scripts are not found, fall back to:
|
|
||||||
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
|
|
||||||
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
|
|
||||||
|
|
||||||
The script handles all checks internally:
|
|
||||||
- Skips if Git is not available
|
|
||||||
- Skips if already inside a Git repository
|
|
||||||
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
|
|
||||||
|
|
||||||
## Customization
|
|
||||||
|
|
||||||
Replace the script to add project-specific Git initialization steps:
|
|
||||||
- Custom `.gitignore` templates
|
|
||||||
- Default branch naming (`git config init.defaultBranch`)
|
|
||||||
- Git LFS setup
|
|
||||||
- Git hooks installation
|
|
||||||
- Commit signing configuration
|
|
||||||
- Git Flow initialization
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
On success:
|
|
||||||
- `✓ Git repository initialized`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed:
|
|
||||||
- Warn the user
|
|
||||||
- Skip repository initialization
|
|
||||||
- The project continues to function without Git (specs can still be created under `specs/`)
|
|
||||||
|
|
||||||
If Git is installed but `git init`, `git add .`, or `git commit` fails:
|
|
||||||
- Surface the error to the user
|
|
||||||
- Stop this command rather than continuing with a partially initialized repository
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
description: "Detect Git remote URL for GitHub integration"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Detect Git Remote URL
|
|
||||||
|
|
||||||
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and return empty:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; cannot determine remote URL
|
|
||||||
```
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
Run the following command to get the remote URL:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git config --get remote.origin.url
|
|
||||||
```
|
|
||||||
|
|
||||||
## Output
|
|
||||||
|
|
||||||
Parse the remote URL and determine:
|
|
||||||
|
|
||||||
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
|
||||||
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
|
||||||
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
|
||||||
|
|
||||||
Supported URL formats:
|
|
||||||
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
|
||||||
- SSH: `git@github.com:<owner>/<repo>.git`
|
|
||||||
|
|
||||||
> [!CAUTION]
|
|
||||||
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
|
||||||
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
|
||||||
- Return an empty result
|
|
||||||
- Do NOT error — other workflows should continue without Git remote information
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
---
|
|
||||||
description: "Validate current branch follows feature branch naming conventions"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Validate Feature Branch
|
|
||||||
|
|
||||||
Validate that the current Git branch follows the expected feature branch naming conventions.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
|
||||||
- If Git is not available, output a warning and skip validation:
|
|
||||||
```
|
|
||||||
[specify] Warning: Git repository not detected; skipped branch validation
|
|
||||||
```
|
|
||||||
|
|
||||||
## Validation Rules
|
|
||||||
|
|
||||||
Get the current branch name:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git rev-parse --abbrev-ref HEAD
|
|
||||||
```
|
|
||||||
|
|
||||||
The branch name must match one of these patterns:
|
|
||||||
|
|
||||||
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
|
|
||||||
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
|
||||||
|
|
||||||
## Execution
|
|
||||||
|
|
||||||
If on a feature branch (matches either pattern):
|
|
||||||
- Output: `✓ On feature branch: <branch-name>`
|
|
||||||
- Check if the corresponding spec directory exists under `specs/`:
|
|
||||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
|
|
||||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
|
||||||
- If spec directory exists: `✓ Spec directory found: <path>`
|
|
||||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
|
||||||
|
|
||||||
If NOT on a feature branch:
|
|
||||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
|
||||||
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
|
||||||
|
|
||||||
## Graceful Degradation
|
|
||||||
|
|
||||||
If Git is not installed or the directory is not a Git repository:
|
|
||||||
- Check the `SPECIFY_FEATURE` environment variable as a fallback
|
|
||||||
- If set, validate that value against the naming patterns
|
|
||||||
- If not set, skip validation with a warning
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
# Git Branching Workflow Extension Configuration
|
|
||||||
# Copied to .specify/extensions/git/git-config.yml on install
|
|
||||||
|
|
||||||
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
|
||||||
branch_numbering: sequential
|
|
||||||
|
|
||||||
# Commit message used by `git commit` during repository initialization
|
|
||||||
init_commit_message: "[Spec Kit] Initial commit"
|
|
||||||
|
|
||||||
# Auto-commit before/after core commands.
|
|
||||||
# Set "default" to enable for all commands, then override per-command.
|
|
||||||
# Each key can be true/false. Message is customizable per-command.
|
|
||||||
auto_commit:
|
|
||||||
default: false
|
|
||||||
before_clarify:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before clarification"
|
|
||||||
before_plan:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before planning"
|
|
||||||
before_tasks:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before task generation"
|
|
||||||
before_implement:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before implementation"
|
|
||||||
before_checklist:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before checklist"
|
|
||||||
before_analyze:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before analysis"
|
|
||||||
before_taskstoissues:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before issue sync"
|
|
||||||
after_constitution:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add project constitution"
|
|
||||||
after_specify:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add specification"
|
|
||||||
after_clarify:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Clarify specification"
|
|
||||||
after_plan:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add implementation plan"
|
|
||||||
after_tasks:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add tasks"
|
|
||||||
after_implement:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Implementation progress"
|
|
||||||
after_checklist:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add checklist"
|
|
||||||
after_analyze:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add analysis report"
|
|
||||||
after_taskstoissues:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Sync tasks to issues"
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
schema_version: "1.0"
|
|
||||||
|
|
||||||
extension:
|
|
||||||
id: git
|
|
||||||
name: "Git Branching Workflow"
|
|
||||||
version: "1.0.0"
|
|
||||||
description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
|
|
||||||
author: spec-kit-core
|
|
||||||
repository: https://github.com/github/spec-kit
|
|
||||||
license: MIT
|
|
||||||
|
|
||||||
requires:
|
|
||||||
speckit_version: ">=0.2.0"
|
|
||||||
tools:
|
|
||||||
- name: git
|
|
||||||
required: false
|
|
||||||
|
|
||||||
provides:
|
|
||||||
commands:
|
|
||||||
- name: speckit.git.feature
|
|
||||||
file: commands/speckit.git.feature.md
|
|
||||||
description: "Create a feature branch with sequential or timestamp numbering"
|
|
||||||
- name: speckit.git.validate
|
|
||||||
file: commands/speckit.git.validate.md
|
|
||||||
description: "Validate current branch follows feature branch naming conventions"
|
|
||||||
- name: speckit.git.remote
|
|
||||||
file: commands/speckit.git.remote.md
|
|
||||||
description: "Detect Git remote URL for GitHub integration"
|
|
||||||
- name: speckit.git.initialize
|
|
||||||
file: commands/speckit.git.initialize.md
|
|
||||||
description: "Initialize a Git repository with an initial commit"
|
|
||||||
- name: speckit.git.commit
|
|
||||||
file: commands/speckit.git.commit.md
|
|
||||||
description: "Auto-commit changes after a Spec Kit command completes"
|
|
||||||
|
|
||||||
config:
|
|
||||||
- name: "git-config.yml"
|
|
||||||
template: "config-template.yml"
|
|
||||||
description: "Git branching configuration"
|
|
||||||
required: false
|
|
||||||
|
|
||||||
hooks:
|
|
||||||
before_constitution:
|
|
||||||
command: speckit.git.initialize
|
|
||||||
optional: false
|
|
||||||
description: "Initialize Git repository before constitution setup"
|
|
||||||
before_specify:
|
|
||||||
command: speckit.git.feature
|
|
||||||
optional: false
|
|
||||||
description: "Create feature branch before specification"
|
|
||||||
before_clarify:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit outstanding changes before clarification?"
|
|
||||||
description: "Auto-commit before spec clarification"
|
|
||||||
before_plan:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit outstanding changes before planning?"
|
|
||||||
description: "Auto-commit before implementation planning"
|
|
||||||
before_tasks:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit outstanding changes before task generation?"
|
|
||||||
description: "Auto-commit before task generation"
|
|
||||||
before_implement:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit outstanding changes before implementation?"
|
|
||||||
description: "Auto-commit before implementation"
|
|
||||||
before_checklist:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit outstanding changes before checklist?"
|
|
||||||
description: "Auto-commit before checklist generation"
|
|
||||||
before_analyze:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit outstanding changes before analysis?"
|
|
||||||
description: "Auto-commit before analysis"
|
|
||||||
before_taskstoissues:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit outstanding changes before issue sync?"
|
|
||||||
description: "Auto-commit before tasks-to-issues conversion"
|
|
||||||
after_constitution:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit constitution changes?"
|
|
||||||
description: "Auto-commit after constitution update"
|
|
||||||
after_specify:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit specification changes?"
|
|
||||||
description: "Auto-commit after specification"
|
|
||||||
after_clarify:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit clarification changes?"
|
|
||||||
description: "Auto-commit after spec clarification"
|
|
||||||
after_plan:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit plan changes?"
|
|
||||||
description: "Auto-commit after implementation planning"
|
|
||||||
after_tasks:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit task changes?"
|
|
||||||
description: "Auto-commit after task generation"
|
|
||||||
after_implement:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit implementation changes?"
|
|
||||||
description: "Auto-commit after implementation"
|
|
||||||
after_checklist:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit checklist changes?"
|
|
||||||
description: "Auto-commit after checklist generation"
|
|
||||||
after_analyze:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit analysis results?"
|
|
||||||
description: "Auto-commit after analysis"
|
|
||||||
after_taskstoissues:
|
|
||||||
command: speckit.git.commit
|
|
||||||
optional: true
|
|
||||||
prompt: "Commit after syncing issues?"
|
|
||||||
description: "Auto-commit after tasks-to-issues conversion"
|
|
||||||
|
|
||||||
tags:
|
|
||||||
- "git"
|
|
||||||
- "branching"
|
|
||||||
- "workflow"
|
|
||||||
|
|
||||||
config:
|
|
||||||
defaults:
|
|
||||||
branch_numbering: sequential
|
|
||||||
init_commit_message: "[Spec Kit] Initial commit"
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
# Git Branching Workflow Extension Configuration
|
|
||||||
# Copied to .specify/extensions/git/git-config.yml on install
|
|
||||||
|
|
||||||
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
|
||||||
branch_numbering: sequential
|
|
||||||
|
|
||||||
# Commit message used by `git commit` during repository initialization
|
|
||||||
init_commit_message: "[Spec Kit] Initial commit"
|
|
||||||
|
|
||||||
# Auto-commit before/after core commands.
|
|
||||||
# Set "default" to enable for all commands, then override per-command.
|
|
||||||
# Each key can be true/false. Message is customizable per-command.
|
|
||||||
auto_commit:
|
|
||||||
default: false
|
|
||||||
before_clarify:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before clarification"
|
|
||||||
before_plan:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before planning"
|
|
||||||
before_tasks:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before task generation"
|
|
||||||
before_implement:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before implementation"
|
|
||||||
before_checklist:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before checklist"
|
|
||||||
before_analyze:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before analysis"
|
|
||||||
before_taskstoissues:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Save progress before issue sync"
|
|
||||||
after_constitution:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add project constitution"
|
|
||||||
after_specify:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add specification"
|
|
||||||
after_clarify:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Clarify specification"
|
|
||||||
after_plan:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add implementation plan"
|
|
||||||
after_tasks:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add tasks"
|
|
||||||
after_implement:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Implementation progress"
|
|
||||||
after_checklist:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add checklist"
|
|
||||||
after_analyze:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Add analysis report"
|
|
||||||
after_taskstoissues:
|
|
||||||
enabled: false
|
|
||||||
message: "[Spec Kit] Sync tasks to issues"
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Git extension: auto-commit.sh
|
|
||||||
# Automatically commit changes after a Spec Kit command completes.
|
|
||||||
# Checks per-command config keys in git-config.yml before committing.
|
|
||||||
#
|
|
||||||
# Usage: auto-commit.sh <event_name>
|
|
||||||
# e.g.: auto-commit.sh after_specify
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
EVENT_NAME="${1:-}"
|
|
||||||
if [ -z "$EVENT_NAME" ]; then
|
|
||||||
echo "Usage: $0 <event_name>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
_find_project_root() {
|
|
||||||
local dir="$1"
|
|
||||||
while [ "$dir" != "/" ]; do
|
|
||||||
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
|
|
||||||
echo "$dir"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
dir="$(dirname "$dir")"
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
|
|
||||||
# Check if git is available
|
|
||||||
if ! command -v git >/dev/null 2>&1; then
|
|
||||||
echo "[specify] Warning: Git not found; skipped auto-commit" >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
||||||
echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Read per-command config from git-config.yml
|
|
||||||
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
|
||||||
_enabled=false
|
|
||||||
_commit_msg=""
|
|
||||||
|
|
||||||
if [ -f "$_config_file" ]; then
|
|
||||||
# Parse the auto_commit section for this event.
|
|
||||||
# Look for auto_commit.<event_name>.enabled and .message
|
|
||||||
# Also check auto_commit.default as fallback.
|
|
||||||
_in_auto_commit=false
|
|
||||||
_in_event=false
|
|
||||||
_default_enabled=false
|
|
||||||
|
|
||||||
while IFS= read -r _line; do
|
|
||||||
# Detect auto_commit: section
|
|
||||||
if echo "$_line" | grep -q '^auto_commit:'; then
|
|
||||||
_in_auto_commit=true
|
|
||||||
_in_event=false
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Exit auto_commit section on next top-level key
|
|
||||||
if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
|
|
||||||
if $_in_auto_commit; then
|
|
||||||
# Check default key
|
|
||||||
if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then
|
|
||||||
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
|
||||||
[ "$_val" = "true" ] && _default_enabled=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Detect our event subsection
|
|
||||||
if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then
|
|
||||||
_in_event=true
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Inside our event subsection
|
|
||||||
if $_in_event; then
|
|
||||||
# Exit on next sibling key (same indent level as event name)
|
|
||||||
if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then
|
|
||||||
_in_event=false
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then
|
|
||||||
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
|
||||||
[ "$_val" = "true" ] && _enabled=true
|
|
||||||
[ "$_val" = "false" ] && _enabled=false
|
|
||||||
fi
|
|
||||||
if echo "$_line" | grep -Eq '[[:space:]]+message:'; then
|
|
||||||
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done < "$_config_file"
|
|
||||||
|
|
||||||
# If event-specific key not found, use default
|
|
||||||
if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then
|
|
||||||
# Only use default if the event wasn't explicitly set to false
|
|
||||||
# Check if event section existed at all
|
|
||||||
if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then
|
|
||||||
_enabled=true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# No config file — auto-commit disabled by default
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$_enabled" != "true" ]; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if there are changes to commit
|
|
||||||
if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
|
|
||||||
echo "[specify] No changes to commit after $EVENT_NAME" >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Derive a human-readable command name from the event
|
|
||||||
# e.g., after_specify -> specify, before_plan -> plan
|
|
||||||
_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//')
|
|
||||||
_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after')
|
|
||||||
|
|
||||||
# Use custom message if configured, otherwise default
|
|
||||||
if [ -z "$_commit_msg" ]; then
|
|
||||||
_commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Stage and commit
|
|
||||||
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
|
|
||||||
_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
|
|
||||||
|
|
||||||
echo "[OK] Changes committed ${_phase} ${_command_name}" >&2
|
|
||||||
@ -1,453 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Git extension: create-new-feature.sh
|
|
||||||
# Adapted from core scripts/bash/create-new-feature.sh for extension layout.
|
|
||||||
# Sources common.sh from the project's installed scripts, falling back to
|
|
||||||
# git-common.sh for minimal git helpers.
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
JSON_MODE=false
|
|
||||||
DRY_RUN=false
|
|
||||||
ALLOW_EXISTING=false
|
|
||||||
SHORT_NAME=""
|
|
||||||
BRANCH_NUMBER=""
|
|
||||||
USE_TIMESTAMP=false
|
|
||||||
ARGS=()
|
|
||||||
i=1
|
|
||||||
while [ $i -le $# ]; do
|
|
||||||
arg="${!i}"
|
|
||||||
case "$arg" in
|
|
||||||
--json)
|
|
||||||
JSON_MODE=true
|
|
||||||
;;
|
|
||||||
--dry-run)
|
|
||||||
DRY_RUN=true
|
|
||||||
;;
|
|
||||||
--allow-existing-branch)
|
|
||||||
ALLOW_EXISTING=true
|
|
||||||
;;
|
|
||||||
--short-name)
|
|
||||||
if [ $((i + 1)) -gt $# ]; then
|
|
||||||
echo 'Error: --short-name requires a value' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
i=$((i + 1))
|
|
||||||
next_arg="${!i}"
|
|
||||||
if [[ "$next_arg" == --* ]]; then
|
|
||||||
echo 'Error: --short-name requires a value' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
SHORT_NAME="$next_arg"
|
|
||||||
;;
|
|
||||||
--number)
|
|
||||||
if [ $((i + 1)) -gt $# ]; then
|
|
||||||
echo 'Error: --number requires a value' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
i=$((i + 1))
|
|
||||||
next_arg="${!i}"
|
|
||||||
if [[ "$next_arg" == --* ]]; then
|
|
||||||
echo 'Error: --number requires a value' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
BRANCH_NUMBER="$next_arg"
|
|
||||||
if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
|
|
||||||
echo 'Error: --number must be a non-negative integer' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
--timestamp)
|
|
||||||
USE_TIMESTAMP=true
|
|
||||||
;;
|
|
||||||
--help|-h)
|
|
||||||
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
|
||||||
echo ""
|
|
||||||
echo "Options:"
|
|
||||||
echo " --json Output in JSON format"
|
|
||||||
echo " --dry-run Compute branch name without creating the branch"
|
|
||||||
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
|
|
||||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
|
||||||
echo " --number N Specify branch number manually (overrides auto-detection)"
|
|
||||||
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
|
||||||
echo " --help, -h Show this help message"
|
|
||||||
echo ""
|
|
||||||
echo "Environment variables:"
|
|
||||||
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
|
||||||
echo ""
|
|
||||||
echo "Examples:"
|
|
||||||
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
|
||||||
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
|
||||||
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
|
|
||||||
echo " GIT_BRANCH_NAME=my-branch $0 'feature description'"
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
ARGS+=("$arg")
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
i=$((i + 1))
|
|
||||||
done
|
|
||||||
|
|
||||||
FEATURE_DESCRIPTION="${ARGS[*]}"
|
|
||||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
|
||||||
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Trim whitespace and validate description is not empty
|
|
||||||
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
|
|
||||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
|
||||||
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Function to get highest number from specs directory
|
|
||||||
get_highest_from_specs() {
|
|
||||||
local specs_dir="$1"
|
|
||||||
local highest=0
|
|
||||||
|
|
||||||
if [ -d "$specs_dir" ]; then
|
|
||||||
for dir in "$specs_dir"/*; do
|
|
||||||
[ -d "$dir" ] || continue
|
|
||||||
dirname=$(basename "$dir")
|
|
||||||
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
|
||||||
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
|
||||||
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
|
|
||||||
number=$((10#$number))
|
|
||||||
if [ "$number" -gt "$highest" ]; then
|
|
||||||
highest=$number
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$highest"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to get highest number from git branches
|
|
||||||
get_highest_from_branches() {
|
|
||||||
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
|
|
||||||
}
|
|
||||||
|
|
||||||
# Extract the highest sequential feature number from a list of ref names (one per line).
|
|
||||||
_extract_highest_number() {
|
|
||||||
local highest=0
|
|
||||||
while IFS= read -r name; do
|
|
||||||
[ -z "$name" ] && continue
|
|
||||||
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
|
||||||
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
|
|
||||||
number=$((10#$number))
|
|
||||||
if [ "$number" -gt "$highest" ]; then
|
|
||||||
highest=$number
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
echo "$highest"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to get highest number from remote branches without fetching (side-effect-free)
|
|
||||||
get_highest_from_remote_refs() {
|
|
||||||
local highest=0
|
|
||||||
|
|
||||||
for remote in $(git remote 2>/dev/null); do
|
|
||||||
local remote_highest
|
|
||||||
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
|
|
||||||
if [ "$remote_highest" -gt "$highest" ]; then
|
|
||||||
highest=$remote_highest
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "$highest"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to check existing branches and return next available number.
|
|
||||||
check_existing_branches() {
|
|
||||||
local specs_dir="$1"
|
|
||||||
local skip_fetch="${2:-false}"
|
|
||||||
|
|
||||||
if [ "$skip_fetch" = true ]; then
|
|
||||||
local highest_remote=$(get_highest_from_remote_refs)
|
|
||||||
local highest_branch=$(get_highest_from_branches)
|
|
||||||
if [ "$highest_remote" -gt "$highest_branch" ]; then
|
|
||||||
highest_branch=$highest_remote
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
git fetch --all --prune >/dev/null 2>&1 || true
|
|
||||||
local highest_branch=$(get_highest_from_branches)
|
|
||||||
fi
|
|
||||||
|
|
||||||
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
|
||||||
|
|
||||||
local max_num=$highest_branch
|
|
||||||
if [ "$highest_spec" -gt "$max_num" ]; then
|
|
||||||
max_num=$highest_spec
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo $((max_num + 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
# Function to clean and format a branch name
|
|
||||||
clean_branch_name() {
|
|
||||||
local name="$1"
|
|
||||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Source common.sh for resolve_template, json_escape, get_repo_root, has_git.
|
|
||||||
#
|
|
||||||
# Search locations in priority order:
|
|
||||||
# 1. .specify/scripts/bash/common.sh under the project root (installed project)
|
|
||||||
# 2. scripts/bash/common.sh under the project root (source checkout fallback)
|
|
||||||
# 3. git-common.sh next to this script (minimal fallback — lacks resolve_template)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
# Find project root by walking up from the script location
|
|
||||||
_find_project_root() {
|
|
||||||
local dir="$1"
|
|
||||||
while [ "$dir" != "/" ]; do
|
|
||||||
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
|
|
||||||
echo "$dir"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
dir="$(dirname "$dir")"
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
_common_loaded=false
|
|
||||||
_PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true
|
|
||||||
|
|
||||||
if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" ]; then
|
|
||||||
source "$_PROJECT_ROOT/.specify/scripts/bash/common.sh"
|
|
||||||
_common_loaded=true
|
|
||||||
elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then
|
|
||||||
source "$_PROJECT_ROOT/scripts/bash/common.sh"
|
|
||||||
_common_loaded=true
|
|
||||||
elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then
|
|
||||||
source "$SCRIPT_DIR/git-common.sh"
|
|
||||||
_common_loaded=true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$_common_loaded" != "true" ]; then
|
|
||||||
echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Resolve repository root
|
|
||||||
if type get_repo_root >/dev/null 2>&1; then
|
|
||||||
REPO_ROOT=$(get_repo_root)
|
|
||||||
elif git rev-parse --show-toplevel >/dev/null 2>&1; then
|
|
||||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
|
||||||
elif [ -n "$_PROJECT_ROOT" ]; then
|
|
||||||
REPO_ROOT="$_PROJECT_ROOT"
|
|
||||||
else
|
|
||||||
echo "Error: Could not determine repository root." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if git is available at this repo root
|
|
||||||
if type has_git >/dev/null 2>&1; then
|
|
||||||
if has_git "$REPO_ROOT"; then
|
|
||||||
HAS_GIT=true
|
|
||||||
else
|
|
||||||
HAS_GIT=false
|
|
||||||
fi
|
|
||||||
elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
||||||
HAS_GIT=true
|
|
||||||
else
|
|
||||||
HAS_GIT=false
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
|
|
||||||
SPECS_DIR="$REPO_ROOT/specs"
|
|
||||||
|
|
||||||
# Function to generate branch name with stop word filtering
|
|
||||||
generate_branch_name() {
|
|
||||||
local description="$1"
|
|
||||||
|
|
||||||
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
|
|
||||||
|
|
||||||
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
|
|
||||||
|
|
||||||
local meaningful_words=()
|
|
||||||
for word in $clean_name; do
|
|
||||||
[ -z "$word" ] && continue
|
|
||||||
if ! echo "$word" | grep -qiE "$stop_words"; then
|
|
||||||
if [ ${#word} -ge 3 ]; then
|
|
||||||
meaningful_words+=("$word")
|
|
||||||
elif echo "$description" | grep -qw -- "${word^^}"; then
|
|
||||||
meaningful_words+=("$word")
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ ${#meaningful_words[@]} -gt 0 ]; then
|
|
||||||
local max_words=3
|
|
||||||
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
|
|
||||||
|
|
||||||
local result=""
|
|
||||||
local count=0
|
|
||||||
for word in "${meaningful_words[@]}"; do
|
|
||||||
if [ $count -ge $max_words ]; then break; fi
|
|
||||||
if [ -n "$result" ]; then result="$result-"; fi
|
|
||||||
result="$result$word"
|
|
||||||
count=$((count + 1))
|
|
||||||
done
|
|
||||||
echo "$result"
|
|
||||||
else
|
|
||||||
local cleaned=$(clean_branch_name "$description")
|
|
||||||
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
|
||||||
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
|
|
||||||
BRANCH_NAME="$GIT_BRANCH_NAME"
|
|
||||||
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
|
|
||||||
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
|
|
||||||
if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
|
||||||
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
|
|
||||||
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
|
|
||||||
elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
|
|
||||||
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
|
|
||||||
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
|
|
||||||
else
|
|
||||||
FEATURE_NUM="$BRANCH_NAME"
|
|
||||||
BRANCH_SUFFIX="$BRANCH_NAME"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
# Generate branch name
|
|
||||||
if [ -n "$SHORT_NAME" ]; then
|
|
||||||
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
|
|
||||||
else
|
|
||||||
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Warn if --number and --timestamp are both specified
|
|
||||||
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
|
|
||||||
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
|
|
||||||
BRANCH_NUMBER=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Determine branch prefix
|
|
||||||
if [ "$USE_TIMESTAMP" = true ]; then
|
|
||||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
|
||||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
|
||||||
else
|
|
||||||
if [ -z "$BRANCH_NUMBER" ]; then
|
|
||||||
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
|
|
||||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
|
|
||||||
elif [ "$DRY_RUN" = true ]; then
|
|
||||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
|
||||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
|
||||||
elif [ "$HAS_GIT" = true ]; then
|
|
||||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
|
||||||
else
|
|
||||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
|
||||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
|
||||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# GitHub enforces a 244-byte limit on branch names
|
|
||||||
MAX_BRANCH_LENGTH=244
|
|
||||||
_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; }
|
|
||||||
BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
|
|
||||||
if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
|
|
||||||
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
|
|
||||||
exit 1
|
|
||||||
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
|
|
||||||
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
|
|
||||||
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
|
||||||
|
|
||||||
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
|
||||||
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
|
||||||
|
|
||||||
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
|
||||||
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
|
||||||
|
|
||||||
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
|
||||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
|
||||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$DRY_RUN" != true ]; then
|
|
||||||
if [ "$HAS_GIT" = true ]; then
|
|
||||||
branch_create_error=""
|
|
||||||
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
|
|
||||||
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
|
||||||
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
|
||||||
if [ "$ALLOW_EXISTING" = true ]; then
|
|
||||||
if [ "$current_branch" = "$BRANCH_NAME" ]; then
|
|
||||||
:
|
|
||||||
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
|
|
||||||
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
|
|
||||||
if [ -n "$switch_branch_error" ]; then
|
|
||||||
>&2 printf '%s\n' "$switch_branch_error"
|
|
||||||
fi
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
elif [ "$USE_TIMESTAMP" = true ]; then
|
|
||||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
|
|
||||||
if [ -n "$branch_create_error" ]; then
|
|
||||||
>&2 printf '%s\n' "$branch_create_error"
|
|
||||||
else
|
|
||||||
>&2 echo "Please check your git configuration and try again."
|
|
||||||
fi
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
|
||||||
fi
|
|
||||||
|
|
||||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
if $JSON_MODE; then
|
|
||||||
if command -v jq >/dev/null 2>&1; then
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
|
||||||
jq -cn \
|
|
||||||
--arg branch_name "$BRANCH_NAME" \
|
|
||||||
--arg feature_num "$FEATURE_NUM" \
|
|
||||||
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}'
|
|
||||||
else
|
|
||||||
jq -cn \
|
|
||||||
--arg branch_name "$BRANCH_NAME" \
|
|
||||||
--arg feature_num "$FEATURE_NUM" \
|
|
||||||
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}'
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
if type json_escape >/dev/null 2>&1; then
|
|
||||||
_je_branch=$(json_escape "$BRANCH_NAME")
|
|
||||||
_je_num=$(json_escape "$FEATURE_NUM")
|
|
||||||
else
|
|
||||||
_je_branch="$BRANCH_NAME"
|
|
||||||
_je_num="$FEATURE_NUM"
|
|
||||||
fi
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
|
||||||
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num"
|
|
||||||
else
|
|
||||||
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "BRANCH_NAME: $BRANCH_NAME"
|
|
||||||
echo "FEATURE_NUM: $FEATURE_NUM"
|
|
||||||
if [ "$DRY_RUN" != true ]; then
|
|
||||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Git-specific common functions for the git extension.
|
|
||||||
# Extracted from scripts/bash/common.sh — contains only git-specific
|
|
||||||
# branch validation and detection logic.
|
|
||||||
|
|
||||||
# Check if we have git available at the repo root
|
|
||||||
has_git() {
|
|
||||||
local repo_root="${1:-$(pwd)}"
|
|
||||||
{ [ -d "$repo_root/.git" ] || [ -f "$repo_root/.git" ]; } && \
|
|
||||||
command -v git >/dev/null 2>&1 && \
|
|
||||||
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
|
|
||||||
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
|
|
||||||
spec_kit_effective_branch_name() {
|
|
||||||
local raw="$1"
|
|
||||||
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
|
|
||||||
printf '%s\n' "${BASH_REMATCH[2]}"
|
|
||||||
else
|
|
||||||
printf '%s\n' "$raw"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Validate that a branch name matches the expected feature branch pattern.
|
|
||||||
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
|
|
||||||
# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization.
|
|
||||||
check_feature_branch() {
|
|
||||||
local raw="$1"
|
|
||||||
local has_git_repo="$2"
|
|
||||||
|
|
||||||
# For non-git repos, we can't enforce branch naming but still provide output
|
|
||||||
if [[ "$has_git_repo" != "true" ]]; then
|
|
||||||
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
local branch
|
|
||||||
branch=$(spec_kit_effective_branch_name "$raw")
|
|
||||||
|
|
||||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
|
||||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
|
||||||
local is_sequential=false
|
|
||||||
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
|
||||||
is_sequential=true
|
|
||||||
fi
|
|
||||||
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
|
||||||
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
|
|
||||||
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Git extension: initialize-repo.sh
|
|
||||||
# Initialize a Git repository with an initial commit.
|
|
||||||
# Customizable — replace this script to add .gitignore templates,
|
|
||||||
# default branch config, git-flow, LFS, signing, etc.
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
|
|
||||||
# Find project root
|
|
||||||
_find_project_root() {
|
|
||||||
local dir="$1"
|
|
||||||
while [ "$dir" != "/" ]; do
|
|
||||||
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
|
|
||||||
echo "$dir"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
dir="$(dirname "$dir")"
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
|
|
||||||
cd "$REPO_ROOT"
|
|
||||||
|
|
||||||
# Read commit message from extension config, fall back to default
|
|
||||||
COMMIT_MSG="[Spec Kit] Initial commit"
|
|
||||||
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
|
||||||
if [ -f "$_config_file" ]; then
|
|
||||||
_msg=$(grep '^init_commit_message:' "$_config_file" 2>/dev/null | sed 's/^init_commit_message:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
|
|
||||||
if [ -n "$_msg" ]; then
|
|
||||||
COMMIT_MSG="$_msg"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if git is available
|
|
||||||
if ! command -v git >/dev/null 2>&1; then
|
|
||||||
echo "[specify] Warning: Git not found; skipped repository initialization" >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if already a git repo
|
|
||||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
||||||
echo "[specify] Git repository already initialized; skipping" >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Initialize
|
|
||||||
_git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_out" >&2; exit 1; }
|
|
||||||
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
|
|
||||||
_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
|
|
||||||
|
|
||||||
echo "✓ Git repository initialized" >&2
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
# Git extension: auto-commit.ps1
|
|
||||||
# Automatically commit changes after a Spec Kit command completes.
|
|
||||||
# Checks per-command config keys in git-config.yml before committing.
|
|
||||||
#
|
|
||||||
# Usage: auto-commit.ps1 <event_name>
|
|
||||||
# e.g.: auto-commit.ps1 after_specify
|
|
||||||
param(
|
|
||||||
[Parameter(Position = 0, Mandatory = $true)]
|
|
||||||
[string]$EventName
|
|
||||||
)
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
function Find-ProjectRoot {
|
|
||||||
param([string]$StartDir)
|
|
||||||
$current = Resolve-Path $StartDir
|
|
||||||
while ($true) {
|
|
||||||
foreach ($marker in @('.specify', '.git')) {
|
|
||||||
if (Test-Path (Join-Path $current $marker)) {
|
|
||||||
return $current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$parent = Split-Path $current -Parent
|
|
||||||
if ($parent -eq $current) { return $null }
|
|
||||||
$current = $parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
|
|
||||||
if (-not $repoRoot) { $repoRoot = Get-Location }
|
|
||||||
Set-Location $repoRoot
|
|
||||||
|
|
||||||
# Check if git is available
|
|
||||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
|
||||||
Write-Warning "[specify] Warning: Git not found; skipped auto-commit"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Temporarily relax ErrorActionPreference so git stderr warnings
|
|
||||||
# (e.g. CRLF notices on Windows) do not become terminating errors.
|
|
||||||
$savedEAP = $ErrorActionPreference
|
|
||||||
$ErrorActionPreference = 'Continue'
|
|
||||||
try {
|
|
||||||
git rev-parse --is-inside-work-tree 2>$null | Out-Null
|
|
||||||
$isRepo = $LASTEXITCODE -eq 0
|
|
||||||
} finally {
|
|
||||||
$ErrorActionPreference = $savedEAP
|
|
||||||
}
|
|
||||||
if (-not $isRepo) {
|
|
||||||
Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Read per-command config from git-config.yml
|
|
||||||
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
|
||||||
$enabled = $false
|
|
||||||
$commitMsg = ""
|
|
||||||
|
|
||||||
if (Test-Path $configFile) {
|
|
||||||
# Parse YAML to find auto_commit section
|
|
||||||
$inAutoCommit = $false
|
|
||||||
$inEvent = $false
|
|
||||||
$defaultEnabled = $false
|
|
||||||
|
|
||||||
foreach ($line in Get-Content $configFile) {
|
|
||||||
# Detect auto_commit: section
|
|
||||||
if ($line -match '^auto_commit:') {
|
|
||||||
$inAutoCommit = $true
|
|
||||||
$inEvent = $false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
# Exit auto_commit section on next top-level key
|
|
||||||
if ($inAutoCommit -and $line -match '^[a-z]') {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($inAutoCommit) {
|
|
||||||
# Check default key
|
|
||||||
if ($line -match '^\s+default:\s*(.+)$') {
|
|
||||||
$val = $matches[1].Trim().ToLower()
|
|
||||||
if ($val -eq 'true') { $defaultEnabled = $true }
|
|
||||||
}
|
|
||||||
|
|
||||||
# Detect our event subsection
|
|
||||||
if ($line -match "^\s+${EventName}:") {
|
|
||||||
$inEvent = $true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
# Inside our event subsection
|
|
||||||
if ($inEvent) {
|
|
||||||
# Exit on next sibling key (2-space indent, not 4+)
|
|
||||||
if ($line -match '^\s{2}[a-z]' -and $line -notmatch '^\s{4}') {
|
|
||||||
$inEvent = $false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if ($line -match '\s+enabled:\s*(.+)$') {
|
|
||||||
$val = $matches[1].Trim().ToLower()
|
|
||||||
if ($val -eq 'true') { $enabled = $true }
|
|
||||||
if ($val -eq 'false') { $enabled = $false }
|
|
||||||
}
|
|
||||||
if ($line -match '\s+message:\s*(.+)$') {
|
|
||||||
$commitMsg = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# If event-specific key not found, use default
|
|
||||||
if (-not $enabled -and $defaultEnabled) {
|
|
||||||
$hasEventKey = Select-String -Path $configFile -Pattern "^\s*${EventName}:" -Quiet
|
|
||||||
if (-not $hasEventKey) {
|
|
||||||
$enabled = $true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
# No config file — auto-commit disabled by default
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $enabled) {
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if there are changes to commit
|
|
||||||
# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate.
|
|
||||||
$savedEAP = $ErrorActionPreference
|
|
||||||
$ErrorActionPreference = 'Continue'
|
|
||||||
try {
|
|
||||||
git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE
|
|
||||||
git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE
|
|
||||||
$untracked = git ls-files --others --exclude-standard 2>$null
|
|
||||||
} finally {
|
|
||||||
$ErrorActionPreference = $savedEAP
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) {
|
|
||||||
Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Derive a human-readable command name from the event
|
|
||||||
$commandName = $EventName -replace '^after_', '' -replace '^before_', ''
|
|
||||||
$phase = if ($EventName -match '^before_') { 'before' } else { 'after' }
|
|
||||||
|
|
||||||
# Use custom message if configured, otherwise default
|
|
||||||
if (-not $commitMsg) {
|
|
||||||
$commitMsg = "[Spec Kit] Auto-commit $phase $commandName"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Stage and commit
|
|
||||||
# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate,
|
|
||||||
# while still allowing redirected error output to be captured for diagnostics.
|
|
||||||
$savedEAP = $ErrorActionPreference
|
|
||||||
$ErrorActionPreference = 'Continue'
|
|
||||||
try {
|
|
||||||
$out = git add . 2>&1 | Out-String
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
|
|
||||||
$out = git commit -q -m $commitMsg 2>&1 | Out-String
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
|
|
||||||
} catch {
|
|
||||||
Write-Warning "[specify] Error: $_"
|
|
||||||
exit 1
|
|
||||||
} finally {
|
|
||||||
$ErrorActionPreference = $savedEAP
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "[OK] Changes committed $phase $commandName"
|
|
||||||
@ -1,403 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
# Git extension: create-new-feature.ps1
|
|
||||||
# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout.
|
|
||||||
# Sources common.ps1 from the project's installed scripts, falling back to
|
|
||||||
# git-common.ps1 for minimal git helpers.
|
|
||||||
[CmdletBinding()]
|
|
||||||
param(
|
|
||||||
[switch]$Json,
|
|
||||||
[switch]$AllowExistingBranch,
|
|
||||||
[switch]$DryRun,
|
|
||||||
[string]$ShortName,
|
|
||||||
[Parameter()]
|
|
||||||
[long]$Number = 0,
|
|
||||||
[switch]$Timestamp,
|
|
||||||
[switch]$Help,
|
|
||||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
|
||||||
[string[]]$FeatureDescription
|
|
||||||
)
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
if ($Help) {
|
|
||||||
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Options:"
|
|
||||||
Write-Host " -Json Output in JSON format"
|
|
||||||
Write-Host " -DryRun Compute branch name without creating the branch"
|
|
||||||
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
|
|
||||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
|
||||||
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
|
||||||
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
|
||||||
Write-Host " -Help Show this help message"
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Environment variables:"
|
|
||||||
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
|
||||||
Write-Host ""
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
|
||||||
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
$featureDesc = ($FeatureDescription -join ' ').Trim()
|
|
||||||
|
|
||||||
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
|
|
||||||
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-HighestNumberFromSpecs {
|
|
||||||
param([string]$SpecsDir)
|
|
||||||
|
|
||||||
[long]$highest = 0
|
|
||||||
if (Test-Path $SpecsDir) {
|
|
||||||
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
|
||||||
if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
|
|
||||||
[long]$num = 0
|
|
||||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
|
||||||
$highest = $num
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $highest
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-HighestNumberFromNames {
|
|
||||||
param([string[]]$Names)
|
|
||||||
|
|
||||||
[long]$highest = 0
|
|
||||||
foreach ($name in $Names) {
|
|
||||||
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
|
|
||||||
[long]$num = 0
|
|
||||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
|
||||||
$highest = $num
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $highest
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-HighestNumberFromBranches {
|
|
||||||
param()
|
|
||||||
|
|
||||||
try {
|
|
||||||
$branches = git branch -a 2>$null
|
|
||||||
if ($LASTEXITCODE -eq 0 -and $branches) {
|
|
||||||
$cleanNames = $branches | ForEach-Object {
|
|
||||||
$_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
|
||||||
}
|
|
||||||
return Get-HighestNumberFromNames -Names $cleanNames
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Write-Verbose "Could not check Git branches: $_"
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-HighestNumberFromRemoteRefs {
|
|
||||||
[long]$highest = 0
|
|
||||||
try {
|
|
||||||
$remotes = git remote 2>$null
|
|
||||||
if ($remotes) {
|
|
||||||
foreach ($remote in $remotes) {
|
|
||||||
$env:GIT_TERMINAL_PROMPT = '0'
|
|
||||||
$refs = git ls-remote --heads $remote 2>$null
|
|
||||||
$env:GIT_TERMINAL_PROMPT = $null
|
|
||||||
if ($LASTEXITCODE -eq 0 -and $refs) {
|
|
||||||
$refNames = $refs | ForEach-Object {
|
|
||||||
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
|
|
||||||
} | Where-Object { $_ }
|
|
||||||
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
|
|
||||||
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Write-Verbose "Could not query remote refs: $_"
|
|
||||||
}
|
|
||||||
return $highest
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-NextBranchNumber {
|
|
||||||
param(
|
|
||||||
[string]$SpecsDir,
|
|
||||||
[switch]$SkipFetch
|
|
||||||
)
|
|
||||||
|
|
||||||
if ($SkipFetch) {
|
|
||||||
$highestBranch = Get-HighestNumberFromBranches
|
|
||||||
$highestRemote = Get-HighestNumberFromRemoteRefs
|
|
||||||
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
git fetch --all --prune 2>$null | Out-Null
|
|
||||||
} catch { }
|
|
||||||
$highestBranch = Get-HighestNumberFromBranches
|
|
||||||
}
|
|
||||||
|
|
||||||
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
|
||||||
$maxNum = [Math]::Max($highestBranch, $highestSpec)
|
|
||||||
return $maxNum + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function ConvertTo-CleanBranchName {
|
|
||||||
param([string]$Name)
|
|
||||||
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
|
||||||
}
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Source common.ps1 from the project's installed scripts.
|
|
||||||
# Search locations in priority order:
|
|
||||||
# 1. .specify/scripts/powershell/common.ps1 under the project root
|
|
||||||
# 2. scripts/powershell/common.ps1 under the project root (source checkout)
|
|
||||||
# 3. git-common.ps1 next to this script (minimal fallback)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
function Find-ProjectRoot {
|
|
||||||
param([string]$StartDir)
|
|
||||||
$current = Resolve-Path $StartDir
|
|
||||||
while ($true) {
|
|
||||||
foreach ($marker in @('.specify', '.git')) {
|
|
||||||
if (Test-Path (Join-Path $current $marker)) {
|
|
||||||
return $current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$parent = Split-Path $current -Parent
|
|
||||||
if ($parent -eq $current) { return $null }
|
|
||||||
$current = $parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot
|
|
||||||
$commonLoaded = $false
|
|
||||||
|
|
||||||
if ($projectRoot) {
|
|
||||||
$candidates = @(
|
|
||||||
(Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"),
|
|
||||||
(Join-Path $projectRoot "scripts/powershell/common.ps1")
|
|
||||||
)
|
|
||||||
foreach ($candidate in $candidates) {
|
|
||||||
if (Test-Path $candidate) {
|
|
||||||
. $candidate
|
|
||||||
$commonLoaded = $true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) {
|
|
||||||
. "$PSScriptRoot/git-common.ps1"
|
|
||||||
$commonLoaded = $true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $commonLoaded) {
|
|
||||||
throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Resolve repository root
|
|
||||||
if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
|
|
||||||
$repoRoot = Get-RepoRoot
|
|
||||||
} elseif ($projectRoot) {
|
|
||||||
$repoRoot = $projectRoot
|
|
||||||
} else {
|
|
||||||
throw "Could not determine repository root."
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if git is available
|
|
||||||
if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
|
|
||||||
# Call without parameters for compatibility with core common.ps1 (no -RepoRoot param)
|
|
||||||
# and git-common.ps1 (has -RepoRoot param with default).
|
|
||||||
$hasGit = Test-HasGit
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
|
|
||||||
$hasGit = ($LASTEXITCODE -eq 0)
|
|
||||||
} catch {
|
|
||||||
$hasGit = $false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Set-Location $repoRoot
|
|
||||||
|
|
||||||
$specsDir = Join-Path $repoRoot 'specs'
|
|
||||||
|
|
||||||
function Get-BranchName {
|
|
||||||
param([string]$Description)
|
|
||||||
|
|
||||||
$stopWords = @(
|
|
||||||
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
|
|
||||||
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
|
||||||
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
|
|
||||||
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
|
|
||||||
'want', 'need', 'add', 'get', 'set'
|
|
||||||
)
|
|
||||||
|
|
||||||
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
|
|
||||||
$words = $cleanName -split '\s+' | Where-Object { $_ }
|
|
||||||
|
|
||||||
$meaningfulWords = @()
|
|
||||||
foreach ($word in $words) {
|
|
||||||
if ($stopWords -contains $word) { continue }
|
|
||||||
if ($word.Length -ge 3) {
|
|
||||||
$meaningfulWords += $word
|
|
||||||
} elseif ($Description -match "\b$($word.ToUpper())\b") {
|
|
||||||
$meaningfulWords += $word
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($meaningfulWords.Count -gt 0) {
|
|
||||||
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
|
|
||||||
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
|
|
||||||
return $result
|
|
||||||
} else {
|
|
||||||
$result = ConvertTo-CleanBranchName -Name $Description
|
|
||||||
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
|
|
||||||
return [string]::Join('-', $fallbackWords)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
|
||||||
if ($env:GIT_BRANCH_NAME) {
|
|
||||||
$branchName = $env:GIT_BRANCH_NAME
|
|
||||||
# Check 244-byte limit (UTF-8) for override names
|
|
||||||
$branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
|
|
||||||
if ($branchNameUtf8ByteCount -gt 244) {
|
|
||||||
throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
|
|
||||||
}
|
|
||||||
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
|
|
||||||
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
|
|
||||||
if ($branchName -match '^(\d{8}-\d{6})-') {
|
|
||||||
$featureNum = $matches[1]
|
|
||||||
} elseif ($branchName -match '^(\d+)-') {
|
|
||||||
$featureNum = $matches[1]
|
|
||||||
} else {
|
|
||||||
$featureNum = $branchName
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if ($ShortName) {
|
|
||||||
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
|
||||||
} else {
|
|
||||||
$branchSuffix = Get-BranchName -Description $featureDesc
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($Timestamp -and $Number -ne 0) {
|
|
||||||
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
|
|
||||||
$Number = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($Timestamp) {
|
|
||||||
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
|
||||||
$branchName = "$featureNum-$branchSuffix"
|
|
||||||
} else {
|
|
||||||
if ($Number -eq 0) {
|
|
||||||
if ($DryRun -and $hasGit) {
|
|
||||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
|
|
||||||
} elseif ($DryRun) {
|
|
||||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
|
||||||
} elseif ($hasGit) {
|
|
||||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
|
||||||
} else {
|
|
||||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$featureNum = ('{0:000}' -f $Number)
|
|
||||||
$branchName = "$featureNum-$branchSuffix"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$maxBranchLength = 244
|
|
||||||
if ($branchName.Length -gt $maxBranchLength) {
|
|
||||||
$prefixLength = $featureNum.Length + 1
|
|
||||||
$maxSuffixLength = $maxBranchLength - $prefixLength
|
|
||||||
|
|
||||||
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
|
||||||
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
|
||||||
|
|
||||||
$originalBranchName = $branchName
|
|
||||||
$branchName = "$featureNum-$truncatedSuffix"
|
|
||||||
|
|
||||||
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
|
|
||||||
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
|
|
||||||
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $DryRun) {
|
|
||||||
if ($hasGit) {
|
|
||||||
$branchCreated = $false
|
|
||||||
$branchCreateError = ''
|
|
||||||
try {
|
|
||||||
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
|
|
||||||
if ($LASTEXITCODE -eq 0) {
|
|
||||||
$branchCreated = $true
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
$branchCreateError = $_.Exception.Message
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not $branchCreated) {
|
|
||||||
$currentBranch = ''
|
|
||||||
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
|
|
||||||
$existingBranch = git branch --list $branchName 2>$null
|
|
||||||
if ($existingBranch) {
|
|
||||||
if ($AllowExistingBranch) {
|
|
||||||
if ($currentBranch -eq $branchName) {
|
|
||||||
# Already on the target branch
|
|
||||||
} else {
|
|
||||||
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
if ($switchBranchError) {
|
|
||||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
|
|
||||||
} else {
|
|
||||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
|
|
||||||
}
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} elseif ($Timestamp) {
|
|
||||||
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
|
||||||
exit 1
|
|
||||||
} else {
|
|
||||||
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if ($branchCreateError) {
|
|
||||||
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
|
|
||||||
} else {
|
|
||||||
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
|
||||||
}
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if ($Json) {
|
|
||||||
[Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName")
|
|
||||||
} else {
|
|
||||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$env:SPECIFY_FEATURE = $branchName
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($Json) {
|
|
||||||
$obj = [PSCustomObject]@{
|
|
||||||
BRANCH_NAME = $branchName
|
|
||||||
FEATURE_NUM = $featureNum
|
|
||||||
HAS_GIT = $hasGit
|
|
||||||
}
|
|
||||||
if ($DryRun) {
|
|
||||||
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
|
|
||||||
}
|
|
||||||
$obj | ConvertTo-Json -Compress
|
|
||||||
} else {
|
|
||||||
Write-Output "BRANCH_NAME: $branchName"
|
|
||||||
Write-Output "FEATURE_NUM: $featureNum"
|
|
||||||
Write-Output "HAS_GIT: $hasGit"
|
|
||||||
if (-not $DryRun) {
|
|
||||||
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
# Git-specific common functions for the git extension.
|
|
||||||
# Extracted from scripts/powershell/common.ps1 — contains only git-specific
|
|
||||||
# branch validation and detection logic.
|
|
||||||
|
|
||||||
function Test-HasGit {
|
|
||||||
param([string]$RepoRoot = (Get-Location))
|
|
||||||
try {
|
|
||||||
if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false }
|
|
||||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false }
|
|
||||||
git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
|
|
||||||
return ($LASTEXITCODE -eq 0)
|
|
||||||
} catch {
|
|
||||||
return $false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function Get-SpecKitEffectiveBranchName {
|
|
||||||
param([string]$Branch)
|
|
||||||
if ($Branch -match '^([^/]+)/([^/]+)$') {
|
|
||||||
return $Matches[2]
|
|
||||||
}
|
|
||||||
return $Branch
|
|
||||||
}
|
|
||||||
|
|
||||||
function Test-FeatureBranch {
|
|
||||||
param(
|
|
||||||
[string]$Branch,
|
|
||||||
[bool]$HasGit = $true
|
|
||||||
)
|
|
||||||
|
|
||||||
# For non-git repos, we can't enforce branch naming but still provide output
|
|
||||||
if (-not $HasGit) {
|
|
||||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw = $Branch
|
|
||||||
$Branch = Get-SpecKitEffectiveBranchName $raw
|
|
||||||
|
|
||||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
|
||||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
|
||||||
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
|
|
||||||
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
|
||||||
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
|
|
||||||
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
|
|
||||||
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
|
|
||||||
return $false
|
|
||||||
}
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
#!/usr/bin/env pwsh
|
|
||||||
# Git extension: initialize-repo.ps1
|
|
||||||
# Initialize a Git repository with an initial commit.
|
|
||||||
# Customizable — replace this script to add .gitignore templates,
|
|
||||||
# default branch config, git-flow, LFS, signing, etc.
|
|
||||||
$ErrorActionPreference = 'Stop'
|
|
||||||
|
|
||||||
# Find project root
|
|
||||||
function Find-ProjectRoot {
|
|
||||||
param([string]$StartDir)
|
|
||||||
$current = Resolve-Path $StartDir
|
|
||||||
while ($true) {
|
|
||||||
foreach ($marker in @('.specify', '.git')) {
|
|
||||||
if (Test-Path (Join-Path $current $marker)) {
|
|
||||||
return $current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$parent = Split-Path $current -Parent
|
|
||||||
if ($parent -eq $current) { return $null }
|
|
||||||
$current = $parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
|
|
||||||
if (-not $repoRoot) { $repoRoot = Get-Location }
|
|
||||||
Set-Location $repoRoot
|
|
||||||
|
|
||||||
# Read commit message from extension config, fall back to default
|
|
||||||
$commitMsg = "[Spec Kit] Initial commit"
|
|
||||||
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
|
||||||
if (Test-Path $configFile) {
|
|
||||||
foreach ($line in Get-Content $configFile) {
|
|
||||||
if ($line -match '^init_commit_message:\s*(.+)$') {
|
|
||||||
$val = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
|
|
||||||
if ($val) { $commitMsg = $val }
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if git is available
|
|
||||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
|
||||||
Write-Warning "[specify] Warning: Git not found; skipped repository initialization"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if already a git repo
|
|
||||||
try {
|
|
||||||
git rev-parse --is-inside-work-tree 2>$null | Out-Null
|
|
||||||
if ($LASTEXITCODE -eq 0) {
|
|
||||||
Write-Warning "[specify] Git repository already initialized; skipping"
|
|
||||||
exit 0
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
|
|
||||||
# Initialize
|
|
||||||
try {
|
|
||||||
$out = git init -q 2>&1 | Out-String
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "git init failed: $out" }
|
|
||||||
$out = git add . 2>&1 | Out-String
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
|
|
||||||
$out = git commit --allow-empty -q -m $commitMsg 2>&1 | Out-String
|
|
||||||
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
|
|
||||||
} catch {
|
|
||||||
Write-Warning "[specify] Error: $_"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host "✓ Git repository initialized"
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"ai": "copilot",
|
|
||||||
"branch_numbering": "sequential",
|
|
||||||
"context_file": ".github/copilot-instructions.md",
|
|
||||||
"here": true,
|
|
||||||
"integration": "copilot",
|
|
||||||
"preset": null,
|
|
||||||
"script": "sh",
|
|
||||||
"speckit_version": "0.7.4"
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"integration": "copilot",
|
|
||||||
"version": "0.7.4"
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"integration": "copilot",
|
|
||||||
"version": "0.7.4",
|
|
||||||
"installed_at": "2026-04-22T21:58:02.962169+00:00",
|
|
||||||
"files": {
|
|
||||||
".github/agents/speckit.analyze.agent.md": "699032fdd49afe31d23c7191f3fe7bcb1d14b081fbc94c2287e6ba3a57574fda",
|
|
||||||
".github/agents/speckit.checklist.agent.md": "d7d691689fe45427c868dcf18ade4df500f0c742a6c91923fefba405d6466dde",
|
|
||||||
".github/agents/speckit.clarify.agent.md": "0cc766dcc5cab233ccdf3bc4cfb5759a6d7d1e13e29f611083046f818f5812bb",
|
|
||||||
".github/agents/speckit.constitution.agent.md": "58d35eb026f56bb7364d91b8b0382d5dd1249ded6c1449a2b69546693afb85f7",
|
|
||||||
".github/agents/speckit.implement.agent.md": "83628415c86ba487b3a083c7a2c0f016c9073abd02c1c7f4a30cff949b6602c0",
|
|
||||||
".github/agents/speckit.plan.agent.md": "2ad128b81ccd8f5bfa78b3b43101f377dfddd8f800fa0856f85bf53b1489b783",
|
|
||||||
".github/agents/speckit.specify.agent.md": "5bbb5270836cc9a3286ce3ed96a500f3d383a54abb06aa11b01a2d2f76dbf39b",
|
|
||||||
".github/agents/speckit.tasks.agent.md": "a58886f29f75e1a14840007772ddd954742aafb3e03d9d1231bee033e6c1626b",
|
|
||||||
".github/agents/speckit.taskstoissues.agent.md": "e84794f7a839126defb364ca815352c5c2b2d20db2d6da399fa53e4ddbb7b3ee",
|
|
||||||
".github/prompts/speckit.analyze.prompt.md": "bb93dbbafa96d07b7cd07fc7061d8adb0c6b26cb772a52d0dce263b1ca2b9b77",
|
|
||||||
".github/prompts/speckit.checklist.prompt.md": "c3aea7526c5cbfd8665acc9508ad5a9a3f71e91a63c36be7bed13a834c3a683c",
|
|
||||||
".github/prompts/speckit.clarify.prompt.md": "ce79b3437ca918d46ac858eb4b8b44d3b0a02c563660c60d94c922a7b5d8d4f4",
|
|
||||||
".github/prompts/speckit.constitution.prompt.md": "38f937279de14387601422ddfda48365debdbaf47b2d513527b8f6d8a27d499d",
|
|
||||||
".github/prompts/speckit.implement.prompt.md": "5053a17fb9238338c63b898ee9c80b2cb4ad1a90c6071fe3748de76864ac6a80",
|
|
||||||
".github/prompts/speckit.plan.prompt.md": "2098dae6bd9277335f31cb150b78bfb1de539c0491798e5cfe382c89ab0bcd0e",
|
|
||||||
".github/prompts/speckit.specify.prompt.md": "7b2cc4dc6462da1c96df46bac4f60e53baba3097f4b24ac3f9b684194458aa98",
|
|
||||||
".github/prompts/speckit.tasks.prompt.md": "88fc57c289f99d5e9d35c255f3e2683f73ecb0a5155dcb4d886f82f52b11841f",
|
|
||||||
".github/prompts/speckit.taskstoissues.prompt.md": "2f9636d4f312a1470f000747cb62677fec0655d8b4e2357fa4fbf238965fa66d"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"integration": "speckit",
|
|
||||||
"version": "0.7.4",
|
|
||||||
"installed_at": "2026-04-22T21:58:02.965809+00:00",
|
|
||||||
"files": {
|
|
||||||
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,30 +1,17 @@
|
|||||||
<!--
|
<!--
|
||||||
Sync Impact Report
|
Sync Impact Report
|
||||||
|
|
||||||
- Version change: 2.7.0 -> 2.8.0
|
- Version change: 2.6.0 -> 2.7.0
|
||||||
- Modified principles: None
|
- Modified principles: None
|
||||||
- Added sections:
|
- Added sections:
|
||||||
- Pre-Production Lean Doctrine (LEAN-001): forbids legacy aliases,
|
- Pre-Production Lean Doctrine (LEAN-001): forbids legacy aliases,
|
||||||
migration shims, dual-write logic, and compatibility fixtures in a
|
migration shims, dual-write logic, and compatibility fixtures in a
|
||||||
pre-production codebase; includes AI-agent verification checklist,
|
pre-production codebase; includes AI-agent verification checklist,
|
||||||
review rule, and explicit exit condition at first production deploy
|
review rule, and explicit exit condition at first production deploy
|
||||||
- Shared Pattern First For Cross-Cutting Interaction Classes
|
|
||||||
(XCUT-001): requires shared contracts/presenters/builders for
|
|
||||||
notifications, status messaging, action links, dashboard signals,
|
|
||||||
navigation, and similar interaction classes before any local
|
|
||||||
domain-specific variant is allowed
|
|
||||||
- Removed sections: None
|
- Removed sections: None
|
||||||
- Templates requiring updates:
|
- Templates requiring updates:
|
||||||
- .specify/templates/spec-template.md: added "Compatibility posture"
|
- .specify/templates/spec-template.md: added "Compatibility posture"
|
||||||
default block ✅
|
default block ✅
|
||||||
- .specify/templates/spec-template.md: add cross-cutting shared-pattern
|
|
||||||
reuse block ✅
|
|
||||||
- .specify/templates/plan-template.md: add shared pattern and system
|
|
||||||
fit section ✅
|
|
||||||
- .specify/templates/tasks-template.md: add cross-cutting reuse task
|
|
||||||
requirements ✅
|
|
||||||
- .specify/templates/checklist-template.md: add shared-pattern reuse
|
|
||||||
review checks ✅
|
|
||||||
- .github/agents/copilot-instructions.md: added "Pre-production
|
- .github/agents/copilot-instructions.md: added "Pre-production
|
||||||
compatibility check" agent checklist ✅
|
compatibility check" agent checklist ✅
|
||||||
- Commands checked:
|
- Commands checked:
|
||||||
@ -83,14 +70,6 @@ ### UI Semantics Must Not Become Their Own Framework (UI-SEM-001)
|
|||||||
- Direct mapping from canonical domain truth to UI is preferred over intermediate semantic superstructures.
|
- Direct mapping from canonical domain truth to UI is preferred over intermediate semantic superstructures.
|
||||||
- Presentation helpers SHOULD remain optional adapters, not mandatory architecture.
|
- Presentation helpers SHOULD remain optional adapters, not mandatory architecture.
|
||||||
|
|
||||||
### Shared Pattern First For Cross-Cutting Interaction Classes (XCUT-001)
|
|
||||||
- Cross-cutting interaction classes such as notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, alerts, evidence/report viewers, and similar operator-facing infrastructure MUST first attach to an existing shared contract, presenter, builder, renderer, or other shared path when one already exists.
|
|
||||||
- New local or domain-specific implementations for an existing interaction class are allowed only when the current shared path is demonstrably insufficient for current-release truth.
|
|
||||||
- The active spec MUST name the shared path being reused or explicitly record the deviation, why the existing path is insufficient, what consistency must still be preserved, and what ownership or spread-control cost the deviation creates.
|
|
||||||
- The same interaction class MUST NOT develop parallel operator-facing UX languages for title/body/action structure, status semantics, action-label patterns, or deep-link behavior unless the deviation is explicit and justified.
|
|
||||||
- Reviews MUST treat undocumented bypass of an existing shared path as drift and block merge until the feature converges on the shared path or records a bounded exception.
|
|
||||||
- If the drift is discovered only after a feature is already implemented, the remedy is NOT to rewrite historical closed specs retroactively by default; instead the active work MUST record the issue as `document-in-feature` or escalate it as `follow-up-spec`, depending on whether the drift is contained or structural.
|
|
||||||
|
|
||||||
### V1 Prefers Explicit Narrow Implementations (V1-EXP-001)
|
### V1 Prefers Explicit Narrow Implementations (V1-EXP-001)
|
||||||
- For V1 and early product maturity, direct implementation, local mapping, explicit per-domain logic, small focused helpers, derived read models, and minimal UI adapters are preferred.
|
- For V1 and early product maturity, direct implementation, local mapping, explicit per-domain logic, small focused helpers, derived read models, and minimal UI adapters are preferred.
|
||||||
- Generic platform engines, meta-frameworks, universal resolver systems, workflow frameworks, and broad semantic taxonomies are disfavored until real variance proves them necessary.
|
- Generic platform engines, meta-frameworks, universal resolver systems, workflow frameworks, and broad semantic taxonomies are disfavored until real variance proves them necessary.
|
||||||
|
|||||||
@ -26,24 +26,18 @@ ## Native, Shared-Family, And State Ownership
|
|||||||
- [ ] CHK005 Shell, page, detail, and URL/query state owners are named once and do not collapse into one another.
|
- [ ] CHK005 Shell, page, detail, and URL/query state owners are named once and do not collapse into one another.
|
||||||
- [ ] CHK006 The likely next operator action and the primary inspect/open model stay coherent with the declared surface class.
|
- [ ] CHK006 The likely next operator action and the primary inspect/open model stay coherent with the declared surface class.
|
||||||
|
|
||||||
## Shared Pattern Reuse
|
|
||||||
|
|
||||||
- [ ] CHK007 Any cross-cutting interaction class is explicitly marked, and the existing shared contract/presenter/builder/renderer path is named once.
|
|
||||||
- [ ] CHK008 The change extends the shared path where it is sufficient, or the deviation is explicitly documented with product reason, preserved consistency, ownership cost, and spread-control.
|
|
||||||
- [ ] CHK009 The change does not create a parallel operator-facing UX language for the same interaction class unless a bounded exception is recorded.
|
|
||||||
|
|
||||||
## Signals, Exceptions, And Test Depth
|
## Signals, Exceptions, And Test Depth
|
||||||
|
|
||||||
- [ ] CHK010 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
|
- [ ] CHK007 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
|
||||||
- [ ] CHK011 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry.
|
- [ ] CHK008 Any deviation from default rules includes a bounded exception record naming the broken rule, product reason, standardized parts, spread-control rule, and the active feature PR close-out entry.
|
||||||
- [ ] CHK012 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
- [ ] CHK009 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
||||||
- [ ] CHK013 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
|
- [ ] CHK010 The chosen test family/lane and any manual smoke are the narrowest honest proof for the declared surface class, and `standard-native-filament` relief is used when no special contract exists.
|
||||||
|
|
||||||
## Review Outcome
|
## Review Outcome
|
||||||
|
|
||||||
- [ ] CHK014 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
- [ ] CHK011 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
||||||
- [ ] CHK015 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
- [ ] CHK012 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
||||||
- [ ] CHK016 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes.
|
- [ ] CHK013 The final note location is explicit: the active feature PR close-out entry for guarded work, or a concise `N/A` note for low-impact changes.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
@ -54,7 +48,7 @@ ## Notes
|
|||||||
- `keep`: the current scope, guardrail handling, and proof depth are justified.
|
- `keep`: the current scope, guardrail handling, and proof depth are justified.
|
||||||
- `split`: the intent is valid, but the scope should narrow before merge.
|
- `split`: the intent is valid, but the scope should narrow before merge.
|
||||||
- `document-in-feature`: the change is acceptable, but the active feature must record the exception, signal handling, or proof notes explicitly.
|
- `document-in-feature`: the change is acceptable, but the active feature must record the exception, signal handling, or proof notes explicitly.
|
||||||
- `follow-up-spec`: the issue is recurring or structural and needs dedicated governance follow-up. For already-implemented historical drift, prefer a follow-up spec or active feature note instead of retroactively rewriting closed specs.
|
- `follow-up-spec`: the issue is recurring or structural and needs dedicated governance follow-up.
|
||||||
- `reject-or-split`: hidden drift, unresolved exception spread, or wrong proof depth blocks merge as proposed.
|
- `reject-or-split`: hidden drift, unresolved exception spread, or wrong proof depth blocks merge as proposed.
|
||||||
- Check items off as completed: `[x]`
|
- Check items off as completed: `[x]`
|
||||||
- Add comments or findings inline
|
- Add comments or findings inline
|
||||||
|
|||||||
@ -1,50 +0,0 @@
|
|||||||
# [PROJECT_NAME] Constitution
|
|
||||||
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
|
||||||
|
|
||||||
## Core Principles
|
|
||||||
|
|
||||||
### [PRINCIPLE_1_NAME]
|
|
||||||
<!-- Example: I. Library-First -->
|
|
||||||
[PRINCIPLE_1_DESCRIPTION]
|
|
||||||
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
|
||||||
|
|
||||||
### [PRINCIPLE_2_NAME]
|
|
||||||
<!-- Example: II. CLI Interface -->
|
|
||||||
[PRINCIPLE_2_DESCRIPTION]
|
|
||||||
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
|
|
||||||
|
|
||||||
### [PRINCIPLE_3_NAME]
|
|
||||||
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
|
||||||
[PRINCIPLE_3_DESCRIPTION]
|
|
||||||
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
|
||||||
|
|
||||||
### [PRINCIPLE_4_NAME]
|
|
||||||
<!-- Example: IV. Integration Testing -->
|
|
||||||
[PRINCIPLE_4_DESCRIPTION]
|
|
||||||
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
|
||||||
|
|
||||||
### [PRINCIPLE_5_NAME]
|
|
||||||
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
|
||||||
[PRINCIPLE_5_DESCRIPTION]
|
|
||||||
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
|
||||||
|
|
||||||
## [SECTION_2_NAME]
|
|
||||||
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
|
||||||
|
|
||||||
[SECTION_2_CONTENT]
|
|
||||||
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
|
|
||||||
|
|
||||||
## [SECTION_3_NAME]
|
|
||||||
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
|
|
||||||
|
|
||||||
[SECTION_3_CONTENT]
|
|
||||||
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
|
|
||||||
|
|
||||||
## Governance
|
|
||||||
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
|
||||||
|
|
||||||
[GOVERNANCE_RULES]
|
|
||||||
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
|
||||||
|
|
||||||
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
|
||||||
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
|
||||||
@ -43,17 +43,6 @@ ## UI / Surface Guardrail Plan
|
|||||||
- **Exception path and spread control**: [none / describe the named exception boundary]
|
- **Exception path and spread control**: [none / describe the named exception boundary]
|
||||||
- **Active feature PR close-out entry**: [Guardrail / Exception / Smoke Coverage / N/A]
|
- **Active feature PR close-out entry**: [Guardrail / Exception / Smoke Coverage / N/A]
|
||||||
|
|
||||||
## Shared Pattern & System Fit
|
|
||||||
|
|
||||||
> **Fill when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, alerts, evidence/report viewers, or any other shared interaction family. Docs-only or template-only work may use concise `N/A`. Carry the same decision forward from the spec instead of renaming it here.**
|
|
||||||
|
|
||||||
- **Cross-cutting feature marker**: [yes / no / N/A]
|
|
||||||
- **Systems touched**: [List the existing shared systems or `N/A`]
|
|
||||||
- **Shared abstractions reused**: [Named contracts / presenters / builders / renderers / helpers or `N/A`]
|
|
||||||
- **New abstraction introduced? why?**: [none / short explanation]
|
|
||||||
- **Why the existing abstraction was sufficient or insufficient**: [Short explanation tied to current-release truth]
|
|
||||||
- **Bounded deviation / spread control**: [none / describe the exception boundary and containment rule]
|
|
||||||
|
|
||||||
## Constitution Check
|
## Constitution Check
|
||||||
|
|
||||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
@ -81,7 +70,6 @@ ## Constitution Check
|
|||||||
- Persisted truth (PERSIST-001): new tables/entities/artifacts represent independent product truth or lifecycle; convenience projections and UI helpers stay derived
|
- Persisted truth (PERSIST-001): new tables/entities/artifacts represent independent product truth or lifecycle; convenience projections and UI helpers stay derived
|
||||||
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
|
- Behavioral state (STATE-001): new states/statuses/reason codes change behavior, routing, permissions, lifecycle, audit, retention, or retry handling; presentation-only distinctions stay derived
|
||||||
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
|
- UI semantics (UI-SEM-001): avoid turning badges, explanation text, trust/confidence labels, or detail summaries into mandatory interpretation frameworks; prefer direct domain-to-UI mapping
|
||||||
- Shared pattern first (XCUT-001): cross-cutting interaction classes reuse existing shared contracts/presenters/builders/renderers first; any deviation is explicit, bounded, and justified against current-release truth
|
|
||||||
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
|
- V1 explicitness / few layers (V1-EXP-001, LAYER-001): prefer direct implementation, local mappings, and small helpers; any new layer replaces an old one or proves the old one cannot serve
|
||||||
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
- Spec discipline / bloat check (SPEC-DISC-001, BLOAT-001): related semantic changes are grouped coherently, and any new enum, DTO/presenter, persisted entity, interface/registry/resolver, or taxonomy includes a proportionality review covering operator problem, insufficiency, narrowness, ownership cost, rejected alternative, and whether it is current-release truth
|
||||||
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
- Badge semantics (BADGE-001): status-like badges use `BadgeCatalog` / `BadgeRenderer`; no ad-hoc mappings; new values include tests
|
||||||
|
|||||||
@ -35,18 +35,6 @@ ## Spec Scope Fields *(mandatory)*
|
|||||||
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
||||||
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
- **Explicit entitlement checks preventing cross-tenant leakage**: [Describe checks]
|
||||||
|
|
||||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
|
||||||
|
|
||||||
- **Cross-cutting feature?**: [yes/no]
|
|
||||||
- **Interaction class(es)**: [notifications / status messaging / header actions / dashboard signals / navigation / reports / etc.]
|
|
||||||
- **Systems touched**: [List shared systems, surfaces, or infrastructure paths]
|
|
||||||
- **Existing pattern(s) to extend**: [Name the existing shared path(s) or write `none`]
|
|
||||||
- **Shared contract / presenter / builder / renderer to reuse**: [Exact class, helper, or surface path, or `none`]
|
|
||||||
- **Why the existing shared path is sufficient or insufficient**: [Short explanation tied to current-release truth]
|
|
||||||
- **Allowed deviation and why**: [none / bounded exception + why]
|
|
||||||
- **Consistency impact**: [What must stay aligned across interaction structure, copy, status semantics, actions, and deep links]
|
|
||||||
- **Review focus**: [What reviewers must verify to prevent parallel local patterns]
|
|
||||||
|
|
||||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
Use this section to classify UI and surface risk once. If the feature does
|
Use this section to classify UI and surface risk once. If the feature does
|
||||||
@ -226,14 +214,6 @@ ## Requirements *(mandatory)*
|
|||||||
If the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table, interface/contract/registry/resolver,
|
If the feature introduces a new enum/status family, DTO/presenter/envelope, persisted entity/table, interface/contract/registry/resolver,
|
||||||
or taxonomy/classification system, the Proportionality Review section above is mandatory.
|
or taxonomy/classification system, the Proportionality Review section above is mandatory.
|
||||||
|
|
||||||
**Constitution alignment (XCUT-001):** If this feature touches a cross-cutting interaction class such as notifications, status messaging,
|
|
||||||
action links, header actions, dashboard signals/cards, alerts, navigation entry points, or evidence/report viewers, the spec MUST:
|
|
||||||
- state whether the feature is cross-cutting,
|
|
||||||
- name the existing shared pattern(s) and shared contract/presenter/builder/renderer to extend,
|
|
||||||
- explain why the existing shared path is sufficient or why it is insufficient for current-release truth,
|
|
||||||
- record any allowed deviation, the consistency it must preserve, and its ownership/spread-control cost,
|
|
||||||
- and make the reviewer focus explicit so parallel local UX paths do not appear silently.
|
|
||||||
|
|
||||||
**Constitution alignment (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe:
|
**Constitution alignment (TEST-GOV-001):** If this feature changes runtime behavior or tests, the spec MUST describe:
|
||||||
- the actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, or `Browser`) and why that classification matches the real proving purpose,
|
- the actual test-purpose classification (`Unit`, `Feature`, `Heavy-Governance`, or `Browser`) and why that classification matches the real proving purpose,
|
||||||
- the affected validation lane(s) and why they are the narrowest sufficient proof,
|
- the affected validation lane(s) and why they are the narrowest sufficient proof,
|
||||||
|
|||||||
@ -46,11 +46,6 @@ # Tasks: [FEATURE NAME]
|
|||||||
- using source/domain terms only where same-screen disambiguation is required,
|
- using source/domain terms only where same-screen disambiguation is required,
|
||||||
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
|
- aligning button labels, modal titles, run titles, notifications, and audit prose to the same domain vocabulary,
|
||||||
- removing implementation-first wording from primary operator-facing copy.
|
- removing implementation-first wording from primary operator-facing copy.
|
||||||
**Cross-Cutting Shared Pattern Reuse (XCUT-001)**: If this feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, navigation entry points, alerts, evidence/report viewers, or another shared interaction family, tasks MUST include:
|
|
||||||
- identifying the existing shared contract/presenter/builder/renderer before local implementation begins,
|
|
||||||
- extending the shared path when it is sufficient for current-release truth,
|
|
||||||
- or recording a bounded exception task that documents why the shared path is insufficient, what consistency must still be preserved, and how spread is controlled,
|
|
||||||
- and ensuring reviewer proof covers whether the feature converged on the shared path or knowingly introduced a bounded exception.
|
|
||||||
**UI / Surface Guardrails**: If this feature adds or changes operator-facing surfaces or the workflow that governs them, tasks MUST include:
|
**UI / Surface Guardrails**: If this feature adds or changes operator-facing surfaces or the workflow that governs them, tasks MUST include:
|
||||||
- carrying forward the spec's native/custom classification, shared-family relevance, state-layer ownership, and exception need into implementation work without renaming the same decision,
|
- carrying forward the spec's native/custom classification, shared-family relevance, state-layer ownership, and exception need into implementation work without renaming the same decision,
|
||||||
- classifying any triggered repository signals with one handling mode (`hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`),
|
- classifying any triggered repository signals with one handling mode (`hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`),
|
||||||
|
|||||||
@ -1,63 +0,0 @@
|
|||||||
schema_version: "1.0"
|
|
||||||
workflow:
|
|
||||||
id: "speckit"
|
|
||||||
name: "Full SDD Cycle"
|
|
||||||
version: "1.0.0"
|
|
||||||
author: "GitHub"
|
|
||||||
description: "Runs specify → plan → tasks → implement with review gates"
|
|
||||||
|
|
||||||
requires:
|
|
||||||
speckit_version: ">=0.7.2"
|
|
||||||
integrations:
|
|
||||||
any: ["copilot", "claude", "gemini"]
|
|
||||||
|
|
||||||
inputs:
|
|
||||||
spec:
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
prompt: "Describe what you want to build"
|
|
||||||
integration:
|
|
||||||
type: string
|
|
||||||
default: "copilot"
|
|
||||||
prompt: "Integration to use (e.g. claude, copilot, gemini)"
|
|
||||||
scope:
|
|
||||||
type: string
|
|
||||||
default: "full"
|
|
||||||
enum: ["full", "backend-only", "frontend-only"]
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- id: specify
|
|
||||||
command: speckit.specify
|
|
||||||
integration: "{{ inputs.integration }}"
|
|
||||||
input:
|
|
||||||
args: "{{ inputs.spec }}"
|
|
||||||
|
|
||||||
- id: review-spec
|
|
||||||
type: gate
|
|
||||||
message: "Review the generated spec before planning."
|
|
||||||
options: [approve, reject]
|
|
||||||
on_reject: abort
|
|
||||||
|
|
||||||
- id: plan
|
|
||||||
command: speckit.plan
|
|
||||||
integration: "{{ inputs.integration }}"
|
|
||||||
input:
|
|
||||||
args: "{{ inputs.spec }}"
|
|
||||||
|
|
||||||
- id: review-plan
|
|
||||||
type: gate
|
|
||||||
message: "Review the plan before generating tasks."
|
|
||||||
options: [approve, reject]
|
|
||||||
on_reject: abort
|
|
||||||
|
|
||||||
- id: tasks
|
|
||||||
command: speckit.tasks
|
|
||||||
integration: "{{ inputs.integration }}"
|
|
||||||
input:
|
|
||||||
args: "{{ inputs.spec }}"
|
|
||||||
|
|
||||||
- id: implement
|
|
||||||
command: speckit.implement
|
|
||||||
integration: "{{ inputs.integration }}"
|
|
||||||
input:
|
|
||||||
args: "{{ inputs.spec }}"
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
{
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"workflows": {
|
|
||||||
"speckit": {
|
|
||||||
"name": "Full SDD Cycle",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates",
|
|
||||||
"source": "bundled",
|
|
||||||
"installed_at": "2026-04-22T21:58:03.039039+00:00",
|
|
||||||
"updated_at": "2026-04-22T21:58:03.039046+00:00"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,659 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Findings;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingExceptionResource;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Services\Findings\FindingAssignmentHygieneService;
|
|
||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
|
||||||
use Filament\Tables\Contracts\HasTable;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class FindingsHygieneReport extends Page implements HasTable
|
|
||||||
{
|
|
||||||
use InteractsWithTable;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-wrench-screwdriver';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Findings hygiene report';
|
|
||||||
|
|
||||||
protected static ?string $slug = 'findings/hygiene';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.findings.findings-hygiene-report';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, Tenant>|null
|
|
||||||
*/
|
|
||||||
private ?array $visibleTenants = null;
|
|
||||||
|
|
||||||
private ?Workspace $workspace = null;
|
|
||||||
|
|
||||||
public string $reasonFilter = FindingAssignmentHygieneService::FILTER_ALL;
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header controls keep the hygiene scope fixed and expose only fixed reason views plus tenant-prefilter recovery when needed.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The hygiene report stays read-only and exposes row click as the only inspect path.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The hygiene report does not expose bulk actions.')
|
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state stays calm and only offers a tenant-prefilter reset when the active tenant filter hides otherwise visible issues.')
|
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Repair remains on the existing tenant finding detail surface.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->reasonFilter = $this->resolveRequestedReasonFilter();
|
|
||||||
$this->authorizePageAccess();
|
|
||||||
|
|
||||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
|
||||||
$this->getTableFiltersSessionKey(),
|
|
||||||
[],
|
|
||||||
request(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->applyRequestedTenantPrefilter();
|
|
||||||
$this->mountInteractsWithTable();
|
|
||||||
$this->normalizeTenantFilterState();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('clear_tenant_filter')
|
|
||||||
->label('Clear tenant filter')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
|
||||||
->action(fn (): mixed => $this->clearTenantFilter()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->query(fn (): Builder => $this->issueBaseQuery())
|
|
||||||
->paginated(TablePaginationProfiles::customPage())
|
|
||||||
->persistFiltersInSession()
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('tenant.name')
|
|
||||||
->label('Tenant'),
|
|
||||||
TextColumn::make('subject_display_name')
|
|
||||||
->label('Finding')
|
|
||||||
->state(fn (Finding $record): string => $record->resolvedSubjectDisplayName() ?? 'Finding #'.$record->getKey())
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('owner')
|
|
||||||
->label('Owner')
|
|
||||||
->state(fn (Finding $record): string => FindingResource::accountableOwnerDisplayFor($record)),
|
|
||||||
TextColumn::make('assignee')
|
|
||||||
->label('Assignee')
|
|
||||||
->state(fn (Finding $record): string => FindingResource::activeAssigneeDisplayFor($record))
|
|
||||||
->description(fn (Finding $record): ?string => $this->assigneeContext($record)),
|
|
||||||
TextColumn::make('due_at')
|
|
||||||
->label('Due')
|
|
||||||
->dateTime()
|
|
||||||
->placeholder('—')
|
|
||||||
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? FindingResource::dueAttentionLabelFor($record)),
|
|
||||||
TextColumn::make('hygiene_reasons')
|
|
||||||
->label('Hygiene reason')
|
|
||||||
->state(fn (Finding $record): string => implode(', ', $this->hygieneService()->reasonLabelsFor($record)))
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('last_workflow_activity')
|
|
||||||
->label('Last workflow activity')
|
|
||||||
->state(fn (Finding $record): mixed => $this->hygieneService()->lastWorkflowActivityAt($record))
|
|
||||||
->dateTime()
|
|
||||||
->placeholder('—')
|
|
||||||
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($this->hygieneService()->lastWorkflowActivityAt($record))),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('tenant_id')
|
|
||||||
->label('Tenant')
|
|
||||||
->options(fn (): array => $this->tenantFilterOptions())
|
|
||||||
->searchable(),
|
|
||||||
])
|
|
||||||
->actions([])
|
|
||||||
->bulkActions([])
|
|
||||||
->recordUrl(fn (Finding $record): string => $this->findingDetailUrl($record))
|
|
||||||
->emptyStateHeading(fn (): string => $this->emptyState()['title'])
|
|
||||||
->emptyStateDescription(fn (): string => $this->emptyState()['body'])
|
|
||||||
->emptyStateIcon(fn (): string => $this->emptyState()['icon'])
|
|
||||||
->emptyStateActions($this->emptyStateActions());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function appliedScope(): array
|
|
||||||
{
|
|
||||||
$tenant = $this->filteredTenant();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace_scoped' => true,
|
|
||||||
'fixed_scope' => 'visible_findings_hygiene_only',
|
|
||||||
'reason_filter' => $this->currentReasonFilter(),
|
|
||||||
'reason_filter_label' => $this->hygieneService()->filterLabel($this->currentReasonFilter()),
|
|
||||||
'tenant_prefilter_source' => $this->tenantPrefilterSource(),
|
|
||||||
'tenant_label' => $tenant?->name,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public function availableFilters(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
[
|
|
||||||
'key' => 'hygiene_scope',
|
|
||||||
'label' => 'Findings hygiene only',
|
|
||||||
'fixed' => true,
|
|
||||||
'options' => [],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => 'tenant',
|
|
||||||
'label' => 'Tenant',
|
|
||||||
'fixed' => false,
|
|
||||||
'options' => collect($this->visibleTenants())
|
|
||||||
->map(fn (Tenant $tenant): array => [
|
|
||||||
'value' => (string) $tenant->getKey(),
|
|
||||||
'label' => (string) $tenant->name,
|
|
||||||
])
|
|
||||||
->values()
|
|
||||||
->all(),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public function availableReasonFilters(): array
|
|
||||||
{
|
|
||||||
$summary = $this->summaryCounts();
|
|
||||||
$currentFilter = $this->currentReasonFilter();
|
|
||||||
|
|
||||||
return [
|
|
||||||
[
|
|
||||||
'key' => FindingAssignmentHygieneService::FILTER_ALL,
|
|
||||||
'label' => 'All issues',
|
|
||||||
'active' => $currentFilter === FindingAssignmentHygieneService::FILTER_ALL,
|
|
||||||
'badge_count' => $summary['unique_issue_count'],
|
|
||||||
'url' => $this->reportUrl(['reason' => null]),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => FindingAssignmentHygieneService::REASON_BROKEN_ASSIGNMENT,
|
|
||||||
'label' => 'Broken assignment',
|
|
||||||
'active' => $currentFilter === FindingAssignmentHygieneService::REASON_BROKEN_ASSIGNMENT,
|
|
||||||
'badge_count' => $summary['broken_assignment_count'],
|
|
||||||
'url' => $this->reportUrl(['reason' => FindingAssignmentHygieneService::REASON_BROKEN_ASSIGNMENT]),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => FindingAssignmentHygieneService::REASON_STALE_IN_PROGRESS,
|
|
||||||
'label' => 'Stale in progress',
|
|
||||||
'active' => $currentFilter === FindingAssignmentHygieneService::REASON_STALE_IN_PROGRESS,
|
|
||||||
'badge_count' => $summary['stale_in_progress_count'],
|
|
||||||
'url' => $this->reportUrl(['reason' => FindingAssignmentHygieneService::REASON_STALE_IN_PROGRESS]),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{unique_issue_count: int, broken_assignment_count: int, stale_in_progress_count: int}
|
|
||||||
*/
|
|
||||||
public function summaryCounts(): array
|
|
||||||
{
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace || ! $user instanceof User) {
|
|
||||||
return [
|
|
||||||
'unique_issue_count' => 0,
|
|
||||||
'broken_assignment_count' => 0,
|
|
||||||
'stale_in_progress_count' => 0,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->hygieneService()->summary(
|
|
||||||
$workspace,
|
|
||||||
$user,
|
|
||||||
$this->currentTenantFilterId(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function emptyState(): array
|
|
||||||
{
|
|
||||||
if ($this->tenantFilterAloneExcludesRows()) {
|
|
||||||
return [
|
|
||||||
'title' => 'No hygiene issues match this tenant scope',
|
|
||||||
'body' => 'Your current tenant filter is hiding hygiene issues that are still visible elsewhere in this workspace.',
|
|
||||||
'icon' => 'heroicon-o-funnel',
|
|
||||||
'action_name' => 'clear_tenant_filter_empty',
|
|
||||||
'action_label' => 'Clear tenant filter',
|
|
||||||
'action_kind' => 'clear_tenant_filter',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->reasonFilterAloneExcludesRows()) {
|
|
||||||
return [
|
|
||||||
'title' => 'No findings match this hygiene reason',
|
|
||||||
'body' => 'The current fixed reason view is narrower than the visible issue set in this workspace.',
|
|
||||||
'icon' => 'heroicon-o-adjustments-horizontal',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'title' => 'No visible hygiene issues right now',
|
|
||||||
'body' => 'Visible broken assignments and stale in-progress work are currently calm across the entitled tenant scope.',
|
|
||||||
'icon' => 'heroicon-o-wrench-screwdriver',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedTableFilters(): void
|
|
||||||
{
|
|
||||||
$this->normalizeTenantFilterState();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function clearTenantFilter(): void
|
|
||||||
{
|
|
||||||
$this->removeTableFilter('tenant_id');
|
|
||||||
$this->resetTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, Tenant>
|
|
||||||
*/
|
|
||||||
public function visibleTenants(): array
|
|
||||||
{
|
|
||||||
if ($this->visibleTenants !== null) {
|
|
||||||
return $this->visibleTenants;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace || ! $user instanceof User) {
|
|
||||||
return $this->visibleTenants = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->visibleTenants = $this->hygieneService()->visibleTenants($workspace, $user);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizePageAccess(): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! app(WorkspaceCapabilityResolver::class)->isMember($user, $workspace)) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function workspace(): ?Workspace
|
|
||||||
{
|
|
||||||
if ($this->workspace instanceof Workspace) {
|
|
||||||
return $this->workspace;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
if (! is_int($workspaceId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->workspace = Workspace::query()->whereKey($workspaceId)->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Builder<Finding>
|
|
||||||
*/
|
|
||||||
private function issueBaseQuery(): Builder
|
|
||||||
{
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace || ! $user instanceof User) {
|
|
||||||
return Finding::query()->whereRaw('1 = 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->hygieneService()->issueQuery(
|
|
||||||
$workspace,
|
|
||||||
$user,
|
|
||||||
tenantId: null,
|
|
||||||
reasonFilter: $this->currentReasonFilter(),
|
|
||||||
applyOrdering: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Builder<Finding>
|
|
||||||
*/
|
|
||||||
private function filteredIssueQuery(bool $includeTenantFilter = true, ?string $reasonFilter = null): Builder
|
|
||||||
{
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace || ! $user instanceof User) {
|
|
||||||
return Finding::query()->whereRaw('1 = 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->hygieneService()->issueQuery(
|
|
||||||
$workspace,
|
|
||||||
$user,
|
|
||||||
tenantId: $includeTenantFilter ? $this->currentTenantFilterId() : null,
|
|
||||||
reasonFilter: $reasonFilter ?? $this->currentReasonFilter(),
|
|
||||||
applyOrdering: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function tenantFilterOptions(): array
|
|
||||||
{
|
|
||||||
return collect($this->visibleTenants())
|
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
|
||||||
(string) $tenant->getKey() => (string) $tenant->name,
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applyRequestedTenantPrefilter(): void
|
|
||||||
{
|
|
||||||
$requestedTenant = request()->query('tenant');
|
|
||||||
|
|
||||||
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->visibleTenants() as $tenant) {
|
|
||||||
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
|
||||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeTenantFilterState(): void
|
|
||||||
{
|
|
||||||
$configuredTenantFilter = data_get($this->currentFiltersState(), 'tenant_id.value');
|
|
||||||
|
|
||||||
if ($configuredTenantFilter === null || $configuredTenantFilter === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->currentTenantFilterId() !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->removeTableFilter('tenant_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function currentFiltersState(): array
|
|
||||||
{
|
|
||||||
$persisted = session()->get($this->getTableFiltersSessionKey(), []);
|
|
||||||
|
|
||||||
return array_replace_recursive(
|
|
||||||
is_array($persisted) ? $persisted : [],
|
|
||||||
$this->tableFilters ?? [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function currentTenantFilterId(): ?int
|
|
||||||
{
|
|
||||||
$tenantFilter = data_get($this->currentFiltersState(), 'tenant_id.value');
|
|
||||||
|
|
||||||
if (! is_numeric($tenantFilter)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantId = (int) $tenantFilter;
|
|
||||||
|
|
||||||
foreach ($this->visibleTenants() as $tenant) {
|
|
||||||
if ((int) $tenant->getKey() === $tenantId) {
|
|
||||||
return $tenantId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function filteredTenant(): ?Tenant
|
|
||||||
{
|
|
||||||
$tenantId = $this->currentTenantFilterId();
|
|
||||||
|
|
||||||
if (! is_int($tenantId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->visibleTenants() as $tenant) {
|
|
||||||
if ((int) $tenant->getKey() === $tenantId) {
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function activeVisibleTenant(): ?Tenant
|
|
||||||
{
|
|
||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
|
||||||
|
|
||||||
if (! $activeTenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->visibleTenants() as $tenant) {
|
|
||||||
if ($tenant->is($activeTenant)) {
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function tenantPrefilterSource(): string
|
|
||||||
{
|
|
||||||
$tenant = $this->filteredTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
$activeTenant = $this->activeVisibleTenant();
|
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant && $activeTenant->is($tenant)) {
|
|
||||||
return 'active_tenant_context';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'explicit_filter';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function assigneeContext(Finding $record): ?string
|
|
||||||
{
|
|
||||||
if (! $this->hygieneService()->recordHasBrokenAssignment($record)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($record->assigneeUser?->trashed()) {
|
|
||||||
return 'Soft-deleted user';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'No current tenant membership';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function tenantFilterAloneExcludesRows(): bool
|
|
||||||
{
|
|
||||||
if ($this->currentTenantFilterId() === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((clone $this->filteredIssueQuery())->exists()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (clone $this->filteredIssueQuery(includeTenantFilter: false))->exists();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function reasonFilterAloneExcludesRows(): bool
|
|
||||||
{
|
|
||||||
if ($this->currentReasonFilter() === FindingAssignmentHygieneService::FILTER_ALL) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((clone $this->filteredIssueQuery())->exists()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (clone $this->filteredIssueQuery(includeTenantFilter: true, reasonFilter: FindingAssignmentHygieneService::FILTER_ALL))->exists();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function findingDetailUrl(Finding $record): string
|
|
||||||
{
|
|
||||||
$tenant = $record->tenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return '#';
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant);
|
|
||||||
|
|
||||||
return $this->appendQuery($url, $this->navigationContext()->toQuery());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function navigationContext(): CanonicalNavigationContext
|
|
||||||
{
|
|
||||||
return new CanonicalNavigationContext(
|
|
||||||
sourceSurface: 'findings.hygiene',
|
|
||||||
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
|
|
||||||
tenantId: $this->currentTenantFilterId(),
|
|
||||||
backLinkLabel: 'Back to findings hygiene',
|
|
||||||
backLinkUrl: $this->reportUrl(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function reportUrl(array $overrides = []): string
|
|
||||||
{
|
|
||||||
$resolvedTenant = array_key_exists('tenant', $overrides)
|
|
||||||
? $overrides['tenant']
|
|
||||||
: $this->filteredTenant()?->external_id;
|
|
||||||
$resolvedReason = array_key_exists('reason', $overrides)
|
|
||||||
? $overrides['reason']
|
|
||||||
: $this->currentReasonFilter();
|
|
||||||
|
|
||||||
return static::getUrl(
|
|
||||||
panel: 'admin',
|
|
||||||
parameters: array_filter([
|
|
||||||
'tenant' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
|
|
||||||
'reason' => is_string($resolvedReason) && $resolvedReason !== FindingAssignmentHygieneService::FILTER_ALL
|
|
||||||
? $resolvedReason
|
|
||||||
: null,
|
|
||||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveRequestedReasonFilter(): string
|
|
||||||
{
|
|
||||||
$requestedFilter = request()->query('reason');
|
|
||||||
$availableFilters = $this->hygieneService()->filterOptions();
|
|
||||||
|
|
||||||
return is_string($requestedFilter) && array_key_exists($requestedFilter, $availableFilters)
|
|
||||||
? $requestedFilter
|
|
||||||
: FindingAssignmentHygieneService::FILTER_ALL;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function currentReasonFilter(): string
|
|
||||||
{
|
|
||||||
$availableFilters = $this->hygieneService()->filterOptions();
|
|
||||||
|
|
||||||
return array_key_exists($this->reasonFilter, $availableFilters)
|
|
||||||
? $this->reasonFilter
|
|
||||||
: FindingAssignmentHygieneService::FILTER_ALL;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, Action>
|
|
||||||
*/
|
|
||||||
private function emptyStateActions(): array
|
|
||||||
{
|
|
||||||
$emptyState = $this->emptyState();
|
|
||||||
|
|
||||||
if (($emptyState['action_kind'] ?? null) !== 'clear_tenant_filter') {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
Action::make((string) $emptyState['action_name'])
|
|
||||||
->label((string) $emptyState['action_label'])
|
|
||||||
->icon('heroicon-o-arrow-right')
|
|
||||||
->color('gray')
|
|
||||||
->action(fn (): mixed => $this->clearTenantFilter()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $query
|
|
||||||
*/
|
|
||||||
private function appendQuery(string $url, array $query): string
|
|
||||||
{
|
|
||||||
if ($query === []) {
|
|
||||||
return $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function hygieneService(): FindingAssignmentHygieneService
|
|
||||||
{
|
|
||||||
return app(FindingAssignmentHygieneService::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,775 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Filament\Pages\Findings;
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingExceptionResource;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Services\Findings\FindingWorkflowService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Badges\BadgeDomain;
|
|
||||||
use App\Support\Badges\BadgeRenderer;
|
|
||||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
|
||||||
use App\Support\Filament\TablePaginationProfiles;
|
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
|
||||||
use App\Support\OperateHub\OperateHubShell;
|
|
||||||
use App\Support\Rbac\UiEnforcement;
|
|
||||||
use App\Support\Rbac\UiTooltips;
|
|
||||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
|
||||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceType;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Pages\Page;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Concerns\InteractsWithTable;
|
|
||||||
use Filament\Tables\Contracts\HasTable;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
use UnitEnum;
|
|
||||||
|
|
||||||
class FindingsIntakeQueue extends Page implements HasTable
|
|
||||||
{
|
|
||||||
use InteractsWithTable;
|
|
||||||
|
|
||||||
protected static bool $isDiscovered = false;
|
|
||||||
|
|
||||||
protected static bool $shouldRegisterNavigation = false;
|
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-inbox-stack';
|
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Governance';
|
|
||||||
|
|
||||||
protected static ?string $title = 'Findings intake';
|
|
||||||
|
|
||||||
protected static ?string $slug = 'findings/intake';
|
|
||||||
|
|
||||||
protected string $view = 'filament.pages.findings.findings-intake-queue';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, Tenant>|null
|
|
||||||
*/
|
|
||||||
private ?array $authorizedTenants = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<int, Tenant>|null
|
|
||||||
*/
|
|
||||||
private ?array $visibleTenants = null;
|
|
||||||
|
|
||||||
private ?Workspace $workspace = null;
|
|
||||||
|
|
||||||
public string $queueView = 'unassigned';
|
|
||||||
|
|
||||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|
||||||
{
|
|
||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
|
||||||
->withListRowPrimaryActionLimit(1)
|
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the shared-unassigned scope fixed and expose only a tenant-prefilter clear action when needed.')
|
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The intake queue keeps Claim finding inline and does not render a secondary More menu on rows.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The intake queue does not expose bulk actions in v1.')
|
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'Empty state stays calm and offers exactly one recovery CTA per branch.')
|
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the existing tenant finding detail page while Claim finding remains the only inline safe shortcut.');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mount(): void
|
|
||||||
{
|
|
||||||
$this->queueView = $this->resolveRequestedQueueView();
|
|
||||||
$this->authorizePageAccess();
|
|
||||||
|
|
||||||
app(CanonicalAdminTenantFilterState::class)->sync(
|
|
||||||
$this->getTableFiltersSessionKey(),
|
|
||||||
[],
|
|
||||||
request(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->applyRequestedTenantPrefilter();
|
|
||||||
$this->mountInteractsWithTable();
|
|
||||||
$this->normalizeTenantFilterState();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('clear_tenant_filter')
|
|
||||||
->label('Clear tenant filter')
|
|
||||||
->icon('heroicon-o-x-mark')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (): bool => $this->currentTenantFilterId() !== null)
|
|
||||||
->action(fn (): mixed => $this->clearTenantFilter()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->query(fn (): Builder => $this->queueViewQuery())
|
|
||||||
->paginated(TablePaginationProfiles::customPage())
|
|
||||||
->persistFiltersInSession()
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('tenant.name')
|
|
||||||
->label('Tenant'),
|
|
||||||
TextColumn::make('subject_display_name')
|
|
||||||
->label('Finding')
|
|
||||||
->state(fn (Finding $record): string => $record->resolvedSubjectDisplayName() ?? 'Finding #'.$record->getKey())
|
|
||||||
->description(fn (Finding $record): ?string => $this->ownerContext($record))
|
|
||||||
->wrap(),
|
|
||||||
TextColumn::make('severity')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingSeverity))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingSeverity))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingSeverity)),
|
|
||||||
TextColumn::make('status')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingStatus))
|
|
||||||
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
|
||||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
|
||||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus))
|
|
||||||
->description(fn (Finding $record): ?string => $this->reopenedCue($record)),
|
|
||||||
TextColumn::make('due_at')
|
|
||||||
->label('Due')
|
|
||||||
->dateTime()
|
|
||||||
->placeholder('—')
|
|
||||||
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? FindingResource::dueAttentionLabelFor($record)),
|
|
||||||
TextColumn::make('intake_reason')
|
|
||||||
->label('Queue reason')
|
|
||||||
->badge()
|
|
||||||
->state(fn (Finding $record): string => $this->queueReason($record))
|
|
||||||
->color(fn (Finding $record): string => $this->queueReasonColor($record)),
|
|
||||||
])
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('tenant_id')
|
|
||||||
->label('Tenant')
|
|
||||||
->options(fn (): array => $this->tenantFilterOptions())
|
|
||||||
->searchable(),
|
|
||||||
])
|
|
||||||
->actions([
|
|
||||||
$this->claimAction(),
|
|
||||||
])
|
|
||||||
->bulkActions([])
|
|
||||||
->recordUrl(fn (Finding $record): string => $this->findingDetailUrl($record))
|
|
||||||
->emptyStateHeading(fn (): string => $this->emptyState()['title'])
|
|
||||||
->emptyStateDescription(fn (): string => $this->emptyState()['body'])
|
|
||||||
->emptyStateIcon(fn (): string => $this->emptyState()['icon'])
|
|
||||||
->emptyStateActions($this->emptyStateActions());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function appliedScope(): array
|
|
||||||
{
|
|
||||||
$tenant = $this->filteredTenant();
|
|
||||||
$queueView = $this->currentQueueView();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'workspace_scoped' => true,
|
|
||||||
'fixed_scope' => 'visible_unassigned_open_findings_only',
|
|
||||||
'queue_view' => $queueView,
|
|
||||||
'queue_view_label' => $this->queueViewLabel($queueView),
|
|
||||||
'tenant_prefilter_source' => $this->tenantPrefilterSource(),
|
|
||||||
'tenant_label' => $tenant?->name,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public function queueViews(): array
|
|
||||||
{
|
|
||||||
$queueView = $this->currentQueueView();
|
|
||||||
|
|
||||||
return [
|
|
||||||
[
|
|
||||||
'key' => 'unassigned',
|
|
||||||
'label' => 'Unassigned',
|
|
||||||
'fixed' => true,
|
|
||||||
'active' => $queueView === 'unassigned',
|
|
||||||
'badge_count' => (clone $this->filteredQueueQuery(queueView: 'unassigned', applyOrdering: false))->count(),
|
|
||||||
'url' => $this->queueUrl(['view' => null]),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => 'needs_triage',
|
|
||||||
'label' => 'Needs triage',
|
|
||||||
'fixed' => true,
|
|
||||||
'active' => $queueView === 'needs_triage',
|
|
||||||
'badge_count' => (clone $this->filteredQueueQuery(queueView: 'needs_triage', applyOrdering: false))->count(),
|
|
||||||
'url' => $this->queueUrl(['view' => 'needs_triage']),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{visible_unassigned: int, visible_needs_triage: int, visible_overdue: int}
|
|
||||||
*/
|
|
||||||
public function summaryCounts(): array
|
|
||||||
{
|
|
||||||
$visibleQuery = $this->filteredQueueQuery(queueView: 'unassigned', applyOrdering: false);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'visible_unassigned' => (clone $visibleQuery)->count(),
|
|
||||||
'visible_needs_triage' => (clone $this->filteredQueueQuery(queueView: 'needs_triage', applyOrdering: false))->count(),
|
|
||||||
'visible_overdue' => (clone $visibleQuery)
|
|
||||||
->whereNotNull('due_at')
|
|
||||||
->where('due_at', '<', now())
|
|
||||||
->count(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function emptyState(): array
|
|
||||||
{
|
|
||||||
if ($this->tenantFilterAloneExcludesRows()) {
|
|
||||||
return [
|
|
||||||
'title' => 'No intake findings match this tenant scope',
|
|
||||||
'body' => 'Your current tenant filter is hiding shared intake work that is still visible elsewhere in this workspace.',
|
|
||||||
'icon' => 'heroicon-o-funnel',
|
|
||||||
'action_name' => 'clear_tenant_filter_empty',
|
|
||||||
'action_label' => 'Clear tenant filter',
|
|
||||||
'action_kind' => 'clear_tenant_filter',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'title' => 'Shared intake is clear',
|
|
||||||
'body' => 'No visible unassigned findings currently need first routing across your entitled tenants. Open your personal queue if you want to continue with claimed work.',
|
|
||||||
'icon' => 'heroicon-o-inbox-stack',
|
|
||||||
'action_name' => 'open_my_findings_empty',
|
|
||||||
'action_label' => 'Open my findings',
|
|
||||||
'action_kind' => 'url',
|
|
||||||
'action_url' => MyFindingsInbox::getUrl(panel: 'admin'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedTableFilters(): void
|
|
||||||
{
|
|
||||||
$this->normalizeTenantFilterState();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function clearTenantFilter(): void
|
|
||||||
{
|
|
||||||
$this->removeTableFilter('tenant_id');
|
|
||||||
$this->resetTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, Tenant>
|
|
||||||
*/
|
|
||||||
public function visibleTenants(): array
|
|
||||||
{
|
|
||||||
if ($this->visibleTenants !== null) {
|
|
||||||
return $this->visibleTenants;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$tenants = $this->authorizedTenants();
|
|
||||||
|
|
||||||
if (! $user instanceof User || $tenants === []) {
|
|
||||||
return $this->visibleTenants = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolver = app(CapabilityResolver::class);
|
|
||||||
$resolver->primeMemberships(
|
|
||||||
$user,
|
|
||||||
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $tenants),
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->visibleTenants = array_values(array_filter(
|
|
||||||
$tenants,
|
|
||||||
fn (Tenant $tenant): bool => $resolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function claimAction(): Action
|
|
||||||
{
|
|
||||||
return UiEnforcement::forTableAction(
|
|
||||||
Action::make('claim')
|
|
||||||
->label('Claim finding')
|
|
||||||
->icon('heroicon-o-user-plus')
|
|
||||||
->color('gray')
|
|
||||||
->visible(fn (Finding $record): bool => $record->assignee_user_id === null && in_array((string) $record->status, Finding::openStatuses(), true))
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading('Claim finding')
|
|
||||||
->modalDescription(function (?Finding $record = null): string {
|
|
||||||
$findingLabel = $record?->resolvedSubjectDisplayName() ?? ($record instanceof Finding ? 'Finding #'.$record->getKey() : 'this finding');
|
|
||||||
$tenantLabel = $record?->tenant?->name ?? 'this tenant';
|
|
||||||
|
|
||||||
return sprintf(
|
|
||||||
'Claim "%s" in %s? It will move into your personal queue, while the accountable owner and lifecycle state stay unchanged.',
|
|
||||||
$findingLabel,
|
|
||||||
$tenantLabel,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
->modalSubmitActionLabel('Claim finding')
|
|
||||||
->action(function (Finding $record): void {
|
|
||||||
$tenant = $record->tenant;
|
|
||||||
$user = auth()->user();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$claimedFinding = app(FindingWorkflowService::class)->claim($record, $tenant, $user);
|
|
||||||
|
|
||||||
Notification::make()
|
|
||||||
->success()
|
|
||||||
->title('Finding claimed')
|
|
||||||
->body('The finding left shared intake and is now assigned to you.')
|
|
||||||
->actions([
|
|
||||||
Action::make('open_my_findings')
|
|
||||||
->label('Open my findings')
|
|
||||||
->url(MyFindingsInbox::getUrl(panel: 'admin')),
|
|
||||||
Action::make('open_finding')
|
|
||||||
->label('Open finding')
|
|
||||||
->url($this->findingDetailUrl($claimedFinding)),
|
|
||||||
])
|
|
||||||
->send();
|
|
||||||
} catch (ConflictHttpException) {
|
|
||||||
Notification::make()
|
|
||||||
->warning()
|
|
||||||
->title('Finding already claimed')
|
|
||||||
->body('Another operator claimed this finding first. The intake queue has been refreshed.')
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->resetTable();
|
|
||||||
|
|
||||||
if (method_exists($this, 'unmountAction')) {
|
|
||||||
$this->unmountAction();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
fn () => null,
|
|
||||||
)
|
|
||||||
->preserveVisibility()
|
|
||||||
->requireCapability(Capabilities::TENANT_FINDINGS_ASSIGN)
|
|
||||||
->tooltip(UiTooltips::INSUFFICIENT_PERMISSION)
|
|
||||||
->apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function authorizePageAccess(): void
|
|
||||||
{
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolver = app(WorkspaceCapabilityResolver::class);
|
|
||||||
|
|
||||||
if (! $resolver->isMember($user, $workspace)) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->visibleTenants() === []) {
|
|
||||||
abort(403);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, Tenant>
|
|
||||||
*/
|
|
||||||
private function authorizedTenants(): array
|
|
||||||
{
|
|
||||||
if ($this->authorizedTenants !== null) {
|
|
||||||
return $this->authorizedTenants;
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = auth()->user();
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
|
|
||||||
if (! $user instanceof User || ! $workspace instanceof Workspace) {
|
|
||||||
return $this->authorizedTenants = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->authorizedTenants = $user->tenants()
|
|
||||||
->where('tenants.workspace_id', (int) $workspace->getKey())
|
|
||||||
->where('tenants.status', 'active')
|
|
||||||
->orderBy('tenants.name')
|
|
||||||
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function workspace(): ?Workspace
|
|
||||||
{
|
|
||||||
if ($this->workspace instanceof Workspace) {
|
|
||||||
return $this->workspace;
|
|
||||||
}
|
|
||||||
|
|
||||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId(request());
|
|
||||||
|
|
||||||
if (! is_int($workspaceId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->workspace = Workspace::query()->whereKey($workspaceId)->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function queueBaseQuery(): Builder
|
|
||||||
{
|
|
||||||
$workspace = $this->workspace();
|
|
||||||
$tenantIds = array_map(
|
|
||||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
|
||||||
$this->visibleTenants(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
|
||||||
return Finding::query()->whereRaw('1 = 0');
|
|
||||||
}
|
|
||||||
|
|
||||||
return Finding::query()
|
|
||||||
->with(['tenant', 'ownerUser', 'assigneeUser'])
|
|
||||||
->withSubjectDisplayName()
|
|
||||||
->where('workspace_id', (int) $workspace->getKey())
|
|
||||||
->whereIn('tenant_id', $tenantIds === [] ? [-1] : $tenantIds)
|
|
||||||
->whereNull('assignee_user_id')
|
|
||||||
->whereIn('status', Finding::openStatuses());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function queueViewQuery(): Builder
|
|
||||||
{
|
|
||||||
return $this->filteredQueueQuery(includeTenantFilter: false, queueView: $this->currentQueueView(), applyOrdering: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function filteredQueueQuery(
|
|
||||||
bool $includeTenantFilter = true,
|
|
||||||
?string $queueView = null,
|
|
||||||
bool $applyOrdering = true,
|
|
||||||
): Builder {
|
|
||||||
$query = $this->queueBaseQuery();
|
|
||||||
$resolvedQueueView = $queueView ?? $this->queueView;
|
|
||||||
|
|
||||||
if ($includeTenantFilter && ($tenantId = $this->currentTenantFilterId()) !== null) {
|
|
||||||
$query->where('tenant_id', $tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($resolvedQueueView === 'needs_triage') {
|
|
||||||
$query->whereIn('status', [
|
|
||||||
Finding::STATUS_NEW,
|
|
||||||
Finding::STATUS_REOPENED,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $applyOrdering) {
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query
|
|
||||||
->orderByRaw(
|
|
||||||
"case
|
|
||||||
when due_at is not null and due_at < ? then 0
|
|
||||||
when status = ? then 1
|
|
||||||
when status = ? then 2
|
|
||||||
else 3
|
|
||||||
end asc",
|
|
||||||
[now(), Finding::STATUS_REOPENED, Finding::STATUS_NEW],
|
|
||||||
)
|
|
||||||
->orderByRaw('case when due_at is null then 1 else 0 end asc')
|
|
||||||
->orderBy('due_at')
|
|
||||||
->orderByDesc('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
private function tenantFilterOptions(): array
|
|
||||||
{
|
|
||||||
return collect($this->visibleTenants())
|
|
||||||
->mapWithKeys(static fn (Tenant $tenant): array => [
|
|
||||||
(string) $tenant->getKey() => (string) $tenant->name,
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applyRequestedTenantPrefilter(): void
|
|
||||||
{
|
|
||||||
$requestedTenant = request()->query('tenant');
|
|
||||||
|
|
||||||
if (! is_string($requestedTenant) && ! is_numeric($requestedTenant)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->visibleTenants() as $tenant) {
|
|
||||||
if ((string) $tenant->getKey() !== (string) $requestedTenant && (string) $tenant->external_id !== (string) $requestedTenant) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->tableFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
|
||||||
$this->tableDeferredFilters['tenant_id']['value'] = (string) $tenant->getKey();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeTenantFilterState(): void
|
|
||||||
{
|
|
||||||
$configuredTenantFilter = data_get($this->currentQueueFiltersState(), 'tenant_id.value');
|
|
||||||
|
|
||||||
if ($configuredTenantFilter === null || $configuredTenantFilter === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->currentTenantFilterId() !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->removeTableFilter('tenant_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function currentQueueFiltersState(): array
|
|
||||||
{
|
|
||||||
$persisted = session()->get($this->getTableFiltersSessionKey(), []);
|
|
||||||
|
|
||||||
return array_replace_recursive(
|
|
||||||
is_array($persisted) ? $persisted : [],
|
|
||||||
$this->tableFilters ?? [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function currentTenantFilterId(): ?int
|
|
||||||
{
|
|
||||||
$tenantFilter = data_get($this->currentQueueFiltersState(), 'tenant_id.value');
|
|
||||||
|
|
||||||
if (! is_numeric($tenantFilter)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenantId = (int) $tenantFilter;
|
|
||||||
|
|
||||||
foreach ($this->visibleTenants() as $tenant) {
|
|
||||||
if ((int) $tenant->getKey() === $tenantId) {
|
|
||||||
return $tenantId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function filteredTenant(): ?Tenant
|
|
||||||
{
|
|
||||||
$tenantId = $this->currentTenantFilterId();
|
|
||||||
|
|
||||||
if (! is_int($tenantId)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->visibleTenants() as $tenant) {
|
|
||||||
if ((int) $tenant->getKey() === $tenantId) {
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function activeVisibleTenant(): ?Tenant
|
|
||||||
{
|
|
||||||
$activeTenant = app(OperateHubShell::class)->activeEntitledTenant(request());
|
|
||||||
|
|
||||||
if (! $activeTenant instanceof Tenant) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->visibleTenants() as $tenant) {
|
|
||||||
if ($tenant->is($activeTenant)) {
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function tenantPrefilterSource(): string
|
|
||||||
{
|
|
||||||
$tenant = $this->filteredTenant();
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
$activeTenant = $this->activeVisibleTenant();
|
|
||||||
|
|
||||||
if ($activeTenant instanceof Tenant && $activeTenant->is($tenant)) {
|
|
||||||
return 'active_tenant_context';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'explicit_filter';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ownerContext(Finding $record): ?string
|
|
||||||
{
|
|
||||||
if ($record->owner_user_id === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Owner: '.FindingResource::accountableOwnerDisplayFor($record);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function reopenedCue(Finding $record): ?string
|
|
||||||
{
|
|
||||||
if ($record->reopened_at === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Reopened';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function queueReason(Finding $record): string
|
|
||||||
{
|
|
||||||
return in_array((string) $record->status, [
|
|
||||||
Finding::STATUS_NEW,
|
|
||||||
Finding::STATUS_REOPENED,
|
|
||||||
], true)
|
|
||||||
? 'Needs triage'
|
|
||||||
: 'Unassigned';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function queueReasonColor(Finding $record): string
|
|
||||||
{
|
|
||||||
return $this->queueReason($record) === 'Needs triage'
|
|
||||||
? 'warning'
|
|
||||||
: 'gray';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function tenantFilterAloneExcludesRows(): bool
|
|
||||||
{
|
|
||||||
if ($this->currentTenantFilterId() === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((clone $this->filteredQueueQuery())->exists()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (clone $this->filteredQueueQuery(includeTenantFilter: false))->exists();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function findingDetailUrl(Finding $record): string
|
|
||||||
{
|
|
||||||
$tenant = $record->tenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return '#';
|
|
||||||
}
|
|
||||||
|
|
||||||
$url = FindingResource::getUrl('view', ['record' => $record], panel: 'tenant', tenant: $tenant);
|
|
||||||
|
|
||||||
return $this->appendQuery($url, $this->navigationContext()->toQuery());
|
|
||||||
}
|
|
||||||
|
|
||||||
private function navigationContext(): CanonicalNavigationContext
|
|
||||||
{
|
|
||||||
return new CanonicalNavigationContext(
|
|
||||||
sourceSurface: 'findings.intake',
|
|
||||||
canonicalRouteName: static::getRouteName(Filament::getPanel('admin')),
|
|
||||||
tenantId: $this->currentTenantFilterId(),
|
|
||||||
backLinkLabel: 'Back to findings intake',
|
|
||||||
backLinkUrl: $this->queueUrl(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function queueUrl(array $overrides = []): string
|
|
||||||
{
|
|
||||||
$resolvedTenant = array_key_exists('tenant', $overrides)
|
|
||||||
? $overrides['tenant']
|
|
||||||
: $this->filteredTenant()?->external_id;
|
|
||||||
$resolvedView = array_key_exists('view', $overrides)
|
|
||||||
? $overrides['view']
|
|
||||||
: $this->currentQueueView();
|
|
||||||
|
|
||||||
return static::getUrl(
|
|
||||||
panel: 'admin',
|
|
||||||
parameters: array_filter([
|
|
||||||
'tenant' => is_string($resolvedTenant) && $resolvedTenant !== '' ? $resolvedTenant : null,
|
|
||||||
'view' => $resolvedView === 'needs_triage' ? 'needs_triage' : null,
|
|
||||||
], static fn (mixed $value): bool => $value !== null && $value !== ''),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveRequestedQueueView(): string
|
|
||||||
{
|
|
||||||
$requestedView = request()->query('view');
|
|
||||||
|
|
||||||
return $requestedView === 'needs_triage'
|
|
||||||
? 'needs_triage'
|
|
||||||
: 'unassigned';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function currentQueueView(): string
|
|
||||||
{
|
|
||||||
return $this->queueView === 'needs_triage'
|
|
||||||
? 'needs_triage'
|
|
||||||
: 'unassigned';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function queueViewLabel(string $queueView): string
|
|
||||||
{
|
|
||||||
return $queueView === 'needs_triage'
|
|
||||||
? 'Needs triage'
|
|
||||||
: 'Unassigned';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, Action>
|
|
||||||
*/
|
|
||||||
private function emptyStateActions(): array
|
|
||||||
{
|
|
||||||
$emptyState = $this->emptyState();
|
|
||||||
$action = Action::make((string) $emptyState['action_name'])
|
|
||||||
->label((string) $emptyState['action_label'])
|
|
||||||
->icon('heroicon-o-arrow-right')
|
|
||||||
->color('gray');
|
|
||||||
|
|
||||||
if (($emptyState['action_kind'] ?? null) === 'clear_tenant_filter') {
|
|
||||||
return [
|
|
||||||
$action->action(fn (): mixed => $this->clearTenantFilter()),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
$action->url((string) $emptyState['action_url']),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $query
|
|
||||||
*/
|
|
||||||
private function appendQuery(string $url, array $query): string
|
|
||||||
{
|
|
||||||
if ($query === []) {
|
|
||||||
return $url;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $url.(str_contains($url, '?') ? '&' : '?').http_build_query($query);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -74,7 +74,6 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
|||||||
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
return ActionSurfaceDeclaration::forPage(ActionSurfaceProfile::ListOnlyReadOnly, ActionSurfaceType::ReadOnlyRegistryReport)
|
||||||
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the assigned-to-me scope fixed and expose only a tenant-prefilter clear action when needed.')
|
->satisfy(ActionSurfaceSlot::ListHeader, 'Header actions keep the assigned-to-me scope fixed and expose only a tenant-prefilter clear action when needed.')
|
||||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||||
->exempt(ActionSurfaceSlot::ListRowMoreMenu, 'The personal findings inbox exposes row click as the only inspect path and does not render a secondary More menu.')
|
|
||||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The personal findings inbox does not expose bulk actions.')
|
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'The personal findings inbox does not expose bulk actions.')
|
||||||
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state stays calm, explains scope boundaries, and offers exactly one recovery CTA.')
|
->satisfy(ActionSurfaceSlot::ListEmptyState, 'The empty state stays calm, explains scope boundaries, and offers exactly one recovery CTA.')
|
||||||
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the existing tenant finding detail page.');
|
->exempt(ActionSurfaceSlot::DetailHeader, 'Row navigation returns to the existing tenant finding detail page.');
|
||||||
|
|||||||
@ -246,22 +246,10 @@ public function blockedExecutionBanner(): ?array
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'detail');
|
|
||||||
$body = 'This run was blocked before the artifact-producing work could finish. Review the summary below for the dominant blocker and next step.';
|
|
||||||
|
|
||||||
if ($reasonEnvelope !== null) {
|
|
||||||
$body = trim(sprintf(
|
|
||||||
'%s %s %s',
|
|
||||||
$body,
|
|
||||||
rtrim($reasonEnvelope->operatorLabel, '.'),
|
|
||||||
$reasonEnvelope->shortExplanation,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tone' => 'amber',
|
'tone' => 'amber',
|
||||||
'title' => 'Blocked by prerequisite',
|
'title' => 'Blocked by prerequisite',
|
||||||
'body' => $body,
|
'body' => 'This run was blocked before the artifact-producing work could finish. Review the summary below for the dominant blocker and next step.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -234,8 +234,7 @@ public static function table(Table $table): Table
|
|||||||
->searchable(),
|
->searchable(),
|
||||||
TextColumn::make('event_type')
|
TextColumn::make('event_type')
|
||||||
->label('Event')
|
->label('Event')
|
||||||
->badge()
|
->badge(),
|
||||||
->formatStateUsing(fn (?string $state): string => AlertRuleResource::eventTypeLabel((string) $state)),
|
|
||||||
TextColumn::make('severity')
|
TextColumn::make('severity')
|
||||||
->badge()
|
->badge()
|
||||||
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
|
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
|
||||||
|
|||||||
@ -380,10 +380,6 @@ public static function eventTypeOptions(): array
|
|||||||
AlertRule::EVENT_SLA_DUE => 'SLA due',
|
AlertRule::EVENT_SLA_DUE => 'SLA due',
|
||||||
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
|
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
|
||||||
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
|
AlertRule::EVENT_ENTRA_ADMIN_ROLES_HIGH => 'Entra admin roles (high privilege)',
|
||||||
AlertRule::EVENT_FINDINGS_ASSIGNED => 'Finding assigned',
|
|
||||||
AlertRule::EVENT_FINDINGS_REOPENED => 'Finding reopened',
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON => 'Finding due soon',
|
|
||||||
AlertRule::EVENT_FINDINGS_OVERDUE => 'Finding overdue',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -764,8 +764,7 @@ public static function table(Table $table): Table
|
|||||||
->dateTime()
|
->dateTime()
|
||||||
->sortable()
|
->sortable()
|
||||||
->placeholder('—')
|
->placeholder('—')
|
||||||
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabelFor($record))
|
->description(fn (Finding $record): ?string => FindingExceptionResource::relativeTimeDescription($record->due_at) ?? static::dueAttentionLabelFor($record)),
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
|
||||||
Tables\Columns\TextColumn::make('ownerUser.name')
|
Tables\Columns\TextColumn::make('ownerUser.name')
|
||||||
->label('Accountable owner')
|
->label('Accountable owner')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
@ -774,10 +773,7 @@ public static function table(Table $table): Table
|
|||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
Tables\Columns\TextColumn::make('subject_external_id')->label('External ID')->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
Tables\Columns\TextColumn::make('scope_key')->label('Scope')->toggleable(isToggledHiddenByDefault: true),
|
||||||
Tables\Columns\TextColumn::make('created_at')
|
Tables\Columns\TextColumn::make('created_at')->since()->label('Created'),
|
||||||
->since()
|
|
||||||
->label('Created')
|
|
||||||
->toggleable(isToggledHiddenByDefault: true),
|
|
||||||
])
|
])
|
||||||
->filters([
|
->filters([
|
||||||
Tables\Filters\Filter::make('open')
|
Tables\Filters\Filter::make('open')
|
||||||
|
|||||||
@ -9,7 +9,6 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Alerts\AlertDispatchService;
|
use App\Services\Alerts\AlertDispatchService;
|
||||||
use App\Services\Findings\FindingNotificationService;
|
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Settings\SettingsResolver;
|
use App\Services\Settings\SettingsResolver;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
@ -22,7 +21,6 @@
|
|||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class EvaluateAlertsJob implements ShouldQueue
|
class EvaluateAlertsJob implements ShouldQueue
|
||||||
@ -34,11 +32,7 @@ public function __construct(
|
|||||||
public ?int $operationRunId = null,
|
public ?int $operationRunId = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(
|
public function handle(AlertDispatchService $dispatchService, OperationRunService $operationRuns): void
|
||||||
AlertDispatchService $dispatchService,
|
|
||||||
OperationRunService $operationRuns,
|
|
||||||
FindingNotificationService $findingNotificationService,
|
|
||||||
): void
|
|
||||||
{
|
{
|
||||||
$workspace = Workspace::query()->whereKey($this->workspaceId)->first();
|
$workspace = Workspace::query()->whereKey($this->workspaceId)->first();
|
||||||
|
|
||||||
@ -73,8 +67,6 @@ public function handle(
|
|||||||
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
|
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
|
||||||
...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart),
|
...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart),
|
||||||
];
|
];
|
||||||
$dueSoonFindings = $this->dueSoonFindings((int) $workspace->getKey());
|
|
||||||
$overdueFindings = $this->overdueFindings((int) $workspace->getKey());
|
|
||||||
|
|
||||||
$createdDeliveries = 0;
|
$createdDeliveries = 0;
|
||||||
|
|
||||||
@ -82,33 +74,13 @@ public function handle(
|
|||||||
$createdDeliveries += $dispatchService->dispatchEvent($workspace, $event);
|
$createdDeliveries += $dispatchService->dispatchEvent($workspace, $event);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($dueSoonFindings as $finding) {
|
|
||||||
$result = $findingNotificationService->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
|
||||||
$createdDeliveries += $result['external_delivery_count'];
|
|
||||||
|
|
||||||
if ($result['direct_delivery_status'] === 'sent') {
|
|
||||||
$createdDeliveries++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($overdueFindings as $finding) {
|
|
||||||
$result = $findingNotificationService->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
$createdDeliveries += $result['external_delivery_count'];
|
|
||||||
|
|
||||||
if ($result['direct_delivery_status'] === 'sent') {
|
|
||||||
$createdDeliveries++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$processedEventCount = count($events) + $dueSoonFindings->count() + $overdueFindings->count();
|
|
||||||
|
|
||||||
$operationRuns->updateRun(
|
$operationRuns->updateRun(
|
||||||
$operationRun,
|
$operationRun,
|
||||||
status: OperationRunStatus::Completed->value,
|
status: OperationRunStatus::Completed->value,
|
||||||
outcome: OperationRunOutcome::Succeeded->value,
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
summaryCounts: [
|
summaryCounts: [
|
||||||
'total' => $processedEventCount,
|
'total' => count($events),
|
||||||
'processed' => $processedEventCount,
|
'processed' => count($events),
|
||||||
'created' => $createdDeliveries,
|
'created' => $createdDeliveries,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -129,45 +101,6 @@ public function handle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Finding>
|
|
||||||
*/
|
|
||||||
private function dueSoonFindings(int $workspaceId): Collection
|
|
||||||
{
|
|
||||||
$now = CarbonImmutable::now('UTC');
|
|
||||||
|
|
||||||
return Finding::query()
|
|
||||||
->with('tenant')
|
|
||||||
->withSubjectDisplayName()
|
|
||||||
->where('workspace_id', $workspaceId)
|
|
||||||
->openWorkflow()
|
|
||||||
->whereNotNull('due_at')
|
|
||||||
->where('due_at', '>', $now)
|
|
||||||
->where('due_at', '<=', $now->addHours(24))
|
|
||||||
->orderBy('due_at')
|
|
||||||
->orderBy('id')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Finding>
|
|
||||||
*/
|
|
||||||
private function overdueFindings(int $workspaceId): Collection
|
|
||||||
{
|
|
||||||
$now = CarbonImmutable::now('UTC');
|
|
||||||
|
|
||||||
return Finding::query()
|
|
||||||
->with('tenant')
|
|
||||||
->withSubjectDisplayName()
|
|
||||||
->where('workspace_id', $workspaceId)
|
|
||||||
->openWorkflow()
|
|
||||||
->whereNotNull('due_at')
|
|
||||||
->where('due_at', '<', $now)
|
|
||||||
->orderBy('due_at')
|
|
||||||
->orderBy('id')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveOperationRun(Workspace $workspace, OperationRunService $operationRuns): ?OperationRun
|
private function resolveOperationRun(Workspace $workspace, OperationRunService $operationRuns): ?OperationRun
|
||||||
{
|
{
|
||||||
if (is_int($this->operationRunId) && $this->operationRunId > 0) {
|
if (is_int($this->operationRunId) && $this->operationRunId > 0) {
|
||||||
|
|||||||
@ -28,14 +28,6 @@ class AlertRule extends Model
|
|||||||
|
|
||||||
public const string EVENT_ENTRA_ADMIN_ROLES_HIGH = 'entra.admin_roles.high';
|
public const string EVENT_ENTRA_ADMIN_ROLES_HIGH = 'entra.admin_roles.high';
|
||||||
|
|
||||||
public const string EVENT_FINDINGS_ASSIGNED = 'findings.assigned';
|
|
||||||
|
|
||||||
public const string EVENT_FINDINGS_REOPENED = 'findings.reopened';
|
|
||||||
|
|
||||||
public const string EVENT_FINDINGS_DUE_SOON = 'findings.due_soon';
|
|
||||||
|
|
||||||
public const string EVENT_FINDINGS_OVERDUE = 'findings.overdue';
|
|
||||||
|
|
||||||
public const string TENANT_SCOPE_ALL = 'all';
|
public const string TENANT_SCOPE_ALL = 'all';
|
||||||
|
|
||||||
public const string TENANT_SCOPE_ALLOWLIST = 'allowlist';
|
public const string TENANT_SCOPE_ALLOWLIST = 'allowlist';
|
||||||
|
|||||||
@ -1,57 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Notifications\Findings;
|
|
||||||
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Notifications\Notification;
|
|
||||||
|
|
||||||
final class FindingEventNotification extends Notification
|
|
||||||
{
|
|
||||||
use Queueable;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $event
|
|
||||||
*/
|
|
||||||
public function __construct(
|
|
||||||
private readonly Finding $finding,
|
|
||||||
private readonly Tenant $tenant,
|
|
||||||
private readonly array $event,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public function via(object $notifiable): array
|
|
||||||
{
|
|
||||||
return ['database'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toDatabase(object $notifiable): array
|
|
||||||
{
|
|
||||||
$message = OperationUxPresenter::findingDatabaseNotificationMessage(
|
|
||||||
$this->finding,
|
|
||||||
$this->tenant,
|
|
||||||
$this->event,
|
|
||||||
);
|
|
||||||
|
|
||||||
$message['finding_event'] = [
|
|
||||||
'event_type' => (string) ($this->event['event_type'] ?? ''),
|
|
||||||
'finding_id' => (int) $this->finding->getKey(),
|
|
||||||
'recipient_reason' => data_get($this->event, 'metadata.recipient_reason'),
|
|
||||||
'fingerprint_key' => (string) ($this->event['fingerprint_key'] ?? ''),
|
|
||||||
'due_cycle_key' => $this->event['due_cycle_key'] ?? null,
|
|
||||||
'tenant_name' => $this->tenant->getFilamentName(),
|
|
||||||
'severity' => (string) ($this->event['severity'] ?? ''),
|
|
||||||
];
|
|
||||||
|
|
||||||
return $message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,8 +3,12 @@
|
|||||||
namespace App\Notifications;
|
namespace App\Notifications;
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Models\PlatformUser;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
|
use App\Support\System\SystemOperationRunLinks;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
@ -23,7 +27,25 @@ public function via(object $notifiable): array
|
|||||||
|
|
||||||
public function toDatabase(object $notifiable): array
|
public function toDatabase(object $notifiable): array
|
||||||
{
|
{
|
||||||
$message = OperationUxPresenter::terminalDatabaseNotificationMessage($this->run, $notifiable);
|
$tenant = $this->run->tenant;
|
||||||
|
$runUrl = match (true) {
|
||||||
|
$notifiable instanceof PlatformUser => SystemOperationRunLinks::view($this->run),
|
||||||
|
$tenant instanceof Tenant => OperationRunLinks::view($this->run, $tenant),
|
||||||
|
default => OperationRunLinks::tenantlessView($this->run),
|
||||||
|
};
|
||||||
|
|
||||||
|
$notification = OperationUxPresenter::terminalDatabaseNotification(
|
||||||
|
run: $this->run,
|
||||||
|
tenant: $tenant instanceof Tenant ? $tenant : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
$notification->actions([
|
||||||
|
\Filament\Actions\Action::make('view_run')
|
||||||
|
->label(OperationRunLinks::openLabel())
|
||||||
|
->url($runUrl),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$message = $notification->getDatabaseMessage();
|
||||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
|
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
|
||||||
|
|
||||||
if ($reasonEnvelope !== null) {
|
if ($reasonEnvelope !== null) {
|
||||||
|
|||||||
@ -3,7 +3,10 @@
|
|||||||
namespace App\Notifications;
|
namespace App\Notifications;
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationCatalog;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
|
use Filament\Notifications\Notification as FilamentNotification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
@ -28,6 +31,31 @@ public function via(object $notifiable): array
|
|||||||
*/
|
*/
|
||||||
public function toDatabase(object $notifiable): array
|
public function toDatabase(object $notifiable): array
|
||||||
{
|
{
|
||||||
return OperationUxPresenter::queuedDatabaseNotificationMessage($this->run, $notifiable);
|
$tenant = $this->run->tenant;
|
||||||
|
|
||||||
|
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||||
|
$wizard = $context['wizard'] ?? null;
|
||||||
|
|
||||||
|
$isManagedTenantOnboardingWizardRun = is_array($wizard)
|
||||||
|
&& ($wizard['flow'] ?? null) === 'managed_tenant_onboarding';
|
||||||
|
|
||||||
|
$operationLabel = OperationCatalog::label((string) $this->run->type);
|
||||||
|
|
||||||
|
$runUrl = match (true) {
|
||||||
|
$isManagedTenantOnboardingWizardRun => OperationRunLinks::tenantlessView($this->run),
|
||||||
|
$tenant instanceof Tenant => OperationRunLinks::view($this->run, $tenant),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return FilamentNotification::make()
|
||||||
|
->title("{$operationLabel} queued")
|
||||||
|
->body('Queued for execution. Open the operation for progress and next steps.')
|
||||||
|
->info()
|
||||||
|
->actions([
|
||||||
|
\Filament\Actions\Action::make('view_run')
|
||||||
|
->label(OperationRunLinks::openLabel())
|
||||||
|
->url($runUrl),
|
||||||
|
])
|
||||||
|
->getDatabaseMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,8 +5,6 @@
|
|||||||
use App\Filament\Pages\Auth\Login;
|
use App\Filament\Pages\Auth\Login;
|
||||||
use App\Filament\Pages\ChooseTenant;
|
use App\Filament\Pages\ChooseTenant;
|
||||||
use App\Filament\Pages\ChooseWorkspace;
|
use App\Filament\Pages\ChooseWorkspace;
|
||||||
use App\Filament\Pages\Findings\FindingsHygieneReport;
|
|
||||||
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
|
||||||
use App\Filament\Pages\Findings\MyFindingsInbox;
|
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||||
use App\Filament\Pages\InventoryCoverage;
|
use App\Filament\Pages\InventoryCoverage;
|
||||||
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
use App\Filament\Pages\Monitoring\FindingExceptionsQueue;
|
||||||
@ -179,8 +177,6 @@ public function panel(Panel $panel): Panel
|
|||||||
InventoryCoverage::class,
|
InventoryCoverage::class,
|
||||||
TenantRequiredPermissions::class,
|
TenantRequiredPermissions::class,
|
||||||
WorkspaceSettings::class,
|
WorkspaceSettings::class,
|
||||||
FindingsHygieneReport::class,
|
|
||||||
FindingsIntakeQueue::class,
|
|
||||||
MyFindingsInbox::class,
|
MyFindingsInbox::class,
|
||||||
FindingExceptionsQueue::class,
|
FindingExceptionsQueue::class,
|
||||||
ReviewRegister::class,
|
ReviewRegister::class,
|
||||||
|
|||||||
@ -186,8 +186,6 @@ private function buildPayload(array $event): array
|
|||||||
return [
|
return [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'body' => $body,
|
'body' => $body,
|
||||||
'event_type' => trim((string) ($event['event_type'] ?? '')),
|
|
||||||
'fingerprint_key' => trim((string) ($event['fingerprint_key'] ?? '')),
|
|
||||||
'metadata' => $metadata,
|
'metadata' => $metadata,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,306 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Findings;
|
|
||||||
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
final class FindingAssignmentHygieneService
|
|
||||||
{
|
|
||||||
public const string FILTER_ALL = 'all';
|
|
||||||
|
|
||||||
public const string REASON_BROKEN_ASSIGNMENT = 'broken_assignment';
|
|
||||||
|
|
||||||
public const string REASON_STALE_IN_PROGRESS = 'stale_in_progress';
|
|
||||||
|
|
||||||
private const string HYGIENE_BASELINE_TIMESTAMP = '1970-01-01 00:00:00';
|
|
||||||
|
|
||||||
private const int STALE_IN_PROGRESS_WINDOW_DAYS = 7;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly CapabilityResolver $capabilityResolver,
|
|
||||||
private readonly FindingWorkflowService $findingWorkflowService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, Tenant>
|
|
||||||
*/
|
|
||||||
public function visibleTenants(Workspace $workspace, User $user): array
|
|
||||||
{
|
|
||||||
$authorizedTenants = $user->tenants()
|
|
||||||
->where('tenants.workspace_id', (int) $workspace->getKey())
|
|
||||||
->where('tenants.status', 'active')
|
|
||||||
->orderBy('tenants.name')
|
|
||||||
->get(['tenants.id', 'tenants.name', 'tenants.external_id', 'tenants.workspace_id'])
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if ($authorizedTenants === []) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->capabilityResolver->primeMemberships(
|
|
||||||
$user,
|
|
||||||
array_map(static fn (Tenant $tenant): int => (int) $tenant->getKey(), $authorizedTenants),
|
|
||||||
);
|
|
||||||
|
|
||||||
return array_values(array_filter(
|
|
||||||
$authorizedTenants,
|
|
||||||
fn (Tenant $tenant): bool => $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Builder<Finding>
|
|
||||||
*/
|
|
||||||
public function issueQuery(
|
|
||||||
Workspace $workspace,
|
|
||||||
User $user,
|
|
||||||
?int $tenantId = null,
|
|
||||||
string $reasonFilter = self::FILTER_ALL,
|
|
||||||
bool $applyOrdering = true,
|
|
||||||
): Builder {
|
|
||||||
$visibleTenants = $this->visibleTenants($workspace, $user);
|
|
||||||
$visibleTenantIds = array_map(
|
|
||||||
static fn (Tenant $tenant): int => (int) $tenant->getKey(),
|
|
||||||
$visibleTenants,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($tenantId !== null && ! in_array($tenantId, $visibleTenantIds, true)) {
|
|
||||||
$visibleTenantIds = [];
|
|
||||||
} elseif ($tenantId !== null) {
|
|
||||||
$visibleTenantIds = [$tenantId];
|
|
||||||
}
|
|
||||||
|
|
||||||
$brokenAssignmentExpression = $this->brokenAssignmentExpression();
|
|
||||||
$lastWorkflowActivityExpression = $this->lastWorkflowActivityExpression();
|
|
||||||
$staleBindings = [$this->staleThreshold()->toDateTimeString()];
|
|
||||||
$staleInProgressExpression = $this->staleInProgressExpression($lastWorkflowActivityExpression);
|
|
||||||
|
|
||||||
$query = Finding::query()
|
|
||||||
->select('findings.*')
|
|
||||||
->selectRaw(
|
|
||||||
"case when {$brokenAssignmentExpression} then 1 else 0 end as hygiene_is_broken_assignment",
|
|
||||||
)
|
|
||||||
->selectRaw("{$lastWorkflowActivityExpression} as hygiene_last_workflow_activity_at")
|
|
||||||
->selectRaw(
|
|
||||||
"case when {$staleInProgressExpression} then 1 else 0 end as hygiene_is_stale_in_progress",
|
|
||||||
$staleBindings,
|
|
||||||
)
|
|
||||||
->selectRaw(
|
|
||||||
"(case when {$brokenAssignmentExpression} then 1 else 0 end + case when {$staleInProgressExpression} then 1 else 0 end) as hygiene_issue_count",
|
|
||||||
$staleBindings,
|
|
||||||
)
|
|
||||||
->with([
|
|
||||||
'tenant',
|
|
||||||
'ownerUser' => static fn ($relation) => $relation->withTrashed(),
|
|
||||||
'assigneeUser' => static fn ($relation) => $relation->withTrashed(),
|
|
||||||
])
|
|
||||||
->withSubjectDisplayName()
|
|
||||||
->join('tenants', 'tenants.id', '=', 'findings.tenant_id')
|
|
||||||
->leftJoin('users as hygiene_assignee_lookup', 'hygiene_assignee_lookup.id', '=', 'findings.assignee_user_id')
|
|
||||||
->leftJoin('tenant_memberships as hygiene_assignee_membership', function ($join): void {
|
|
||||||
$join
|
|
||||||
->on('hygiene_assignee_membership.tenant_id', '=', 'findings.tenant_id')
|
|
||||||
->on('hygiene_assignee_membership.user_id', '=', 'findings.assignee_user_id');
|
|
||||||
})
|
|
||||||
->leftJoinSub(
|
|
||||||
$this->latestMeaningfulWorkflowAuditSubquery(),
|
|
||||||
'hygiene_workflow_audit',
|
|
||||||
function ($join): void {
|
|
||||||
$join
|
|
||||||
->on('hygiene_workflow_audit.workspace_id', '=', 'findings.workspace_id')
|
|
||||||
->on('hygiene_workflow_audit.tenant_id', '=', 'findings.tenant_id')
|
|
||||||
->whereRaw('hygiene_workflow_audit.resource_id = '.$this->castFindingIdToAuditResourceId());
|
|
||||||
},
|
|
||||||
)
|
|
||||||
->where('findings.workspace_id', (int) $workspace->getKey())
|
|
||||||
->whereIn('findings.tenant_id', $visibleTenantIds === [] ? [-1] : $visibleTenantIds)
|
|
||||||
->whereIn('findings.status', Finding::openStatusesForQuery())
|
|
||||||
->where(function (Builder $builder) use ($brokenAssignmentExpression, $staleInProgressExpression, $staleBindings): void {
|
|
||||||
$builder
|
|
||||||
->whereRaw($brokenAssignmentExpression)
|
|
||||||
->orWhereRaw($staleInProgressExpression, $staleBindings);
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->applyReasonFilter($query, $reasonFilter, $brokenAssignmentExpression, $staleInProgressExpression, $staleBindings);
|
|
||||||
|
|
||||||
if (! $applyOrdering) {
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query
|
|
||||||
->orderByRaw(
|
|
||||||
"case when {$brokenAssignmentExpression} then 0 when {$staleInProgressExpression} then 1 else 2 end asc",
|
|
||||||
$staleBindings,
|
|
||||||
)
|
|
||||||
->orderByRaw("case when {$lastWorkflowActivityExpression} is null then 1 else 0 end asc")
|
|
||||||
->orderByRaw("{$lastWorkflowActivityExpression} asc")
|
|
||||||
->orderByRaw('case when findings.due_at is null then 1 else 0 end asc')
|
|
||||||
->orderBy('findings.due_at')
|
|
||||||
->orderBy('tenants.name')
|
|
||||||
->orderByDesc('findings.id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{unique_issue_count: int, broken_assignment_count: int, stale_in_progress_count: int}
|
|
||||||
*/
|
|
||||||
public function summary(Workspace $workspace, User $user, ?int $tenantId = null): array
|
|
||||||
{
|
|
||||||
$allIssues = $this->issueQuery($workspace, $user, $tenantId, self::FILTER_ALL, applyOrdering: false);
|
|
||||||
$brokenAssignments = $this->issueQuery($workspace, $user, $tenantId, self::REASON_BROKEN_ASSIGNMENT, applyOrdering: false);
|
|
||||||
$staleInProgress = $this->issueQuery($workspace, $user, $tenantId, self::REASON_STALE_IN_PROGRESS, applyOrdering: false);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'unique_issue_count' => (clone $allIssues)->count(),
|
|
||||||
'broken_assignment_count' => (clone $brokenAssignments)->count(),
|
|
||||||
'stale_in_progress_count' => (clone $staleInProgress)->count(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public function filterOptions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
self::FILTER_ALL => 'All issues',
|
|
||||||
self::REASON_BROKEN_ASSIGNMENT => 'Broken assignment',
|
|
||||||
self::REASON_STALE_IN_PROGRESS => 'Stale in progress',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function filterLabel(string $filter): string
|
|
||||||
{
|
|
||||||
return $this->filterOptions()[$filter] ?? $this->filterOptions()[self::FILTER_ALL];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
public function reasonLabelsFor(Finding $finding): array
|
|
||||||
{
|
|
||||||
$labels = [];
|
|
||||||
|
|
||||||
if ($this->recordHasBrokenAssignment($finding)) {
|
|
||||||
$labels[] = 'Broken assignment';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->recordHasStaleInProgress($finding)) {
|
|
||||||
$labels[] = 'Stale in progress';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $labels;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function lastWorkflowActivityAt(Finding $finding): ?CarbonImmutable
|
|
||||||
{
|
|
||||||
return $this->findingWorkflowService->lastMeaningfulActivityAt(
|
|
||||||
$finding,
|
|
||||||
$finding->getAttribute('hygiene_last_workflow_activity_at'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function recordHasBrokenAssignment(Finding $finding): bool
|
|
||||||
{
|
|
||||||
return (int) ($finding->getAttribute('hygiene_is_broken_assignment') ?? 0) === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function recordHasStaleInProgress(Finding $finding): bool
|
|
||||||
{
|
|
||||||
return (int) ($finding->getAttribute('hygiene_is_stale_in_progress') ?? 0) === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function applyReasonFilter(
|
|
||||||
Builder $query,
|
|
||||||
string $reasonFilter,
|
|
||||||
string $brokenAssignmentExpression,
|
|
||||||
string $staleInProgressExpression,
|
|
||||||
array $staleBindings,
|
|
||||||
): void {
|
|
||||||
$resolvedFilter = array_key_exists($reasonFilter, $this->filterOptions())
|
|
||||||
? $reasonFilter
|
|
||||||
: self::FILTER_ALL;
|
|
||||||
|
|
||||||
if ($resolvedFilter === self::REASON_BROKEN_ASSIGNMENT) {
|
|
||||||
$query->whereRaw($brokenAssignmentExpression);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($resolvedFilter === self::REASON_STALE_IN_PROGRESS) {
|
|
||||||
$query->whereRaw($staleInProgressExpression, $staleBindings);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Builder<AuditLog>
|
|
||||||
*/
|
|
||||||
private function latestMeaningfulWorkflowAuditSubquery(): Builder
|
|
||||||
{
|
|
||||||
return AuditLog::query()
|
|
||||||
->selectRaw('workspace_id, tenant_id, resource_id, max(recorded_at) as latest_workflow_activity_at')
|
|
||||||
->where('resource_type', 'finding')
|
|
||||||
->whereIn('action', FindingWorkflowService::meaningfulActivityActionValues())
|
|
||||||
->groupBy('workspace_id', 'tenant_id', 'resource_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function brokenAssignmentExpression(): string
|
|
||||||
{
|
|
||||||
return '(findings.assignee_user_id is not null and ((hygiene_assignee_lookup.id is not null and hygiene_assignee_lookup.deleted_at is not null) or hygiene_assignee_membership.id is null))';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function staleInProgressExpression(string $lastWorkflowActivityExpression): string
|
|
||||||
{
|
|
||||||
return sprintf(
|
|
||||||
"(findings.status = '%s' and %s is not null and %s < ?)",
|
|
||||||
Finding::STATUS_IN_PROGRESS,
|
|
||||||
$lastWorkflowActivityExpression,
|
|
||||||
$lastWorkflowActivityExpression,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function lastWorkflowActivityExpression(): string
|
|
||||||
{
|
|
||||||
$baseline = "'".self::HYGIENE_BASELINE_TIMESTAMP."'";
|
|
||||||
$greatestExpression = match ($this->connectionDriver()) {
|
|
||||||
'pgsql', 'mysql' => sprintf(
|
|
||||||
'greatest(coalesce(findings.in_progress_at, %1$s), coalesce(findings.reopened_at, %1$s), coalesce(hygiene_workflow_audit.latest_workflow_activity_at, %1$s))',
|
|
||||||
$baseline,
|
|
||||||
),
|
|
||||||
default => sprintf(
|
|
||||||
'max(coalesce(findings.in_progress_at, %1$s), coalesce(findings.reopened_at, %1$s), coalesce(hygiene_workflow_audit.latest_workflow_activity_at, %1$s))',
|
|
||||||
$baseline,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
return sprintf('nullif(%s, %s)', $greatestExpression, $baseline);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function castFindingIdToAuditResourceId(): string
|
|
||||||
{
|
|
||||||
return match ($this->connectionDriver()) {
|
|
||||||
'pgsql' => 'findings.id::text',
|
|
||||||
'mysql' => 'cast(findings.id as char)',
|
|
||||||
default => 'cast(findings.id as text)',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function connectionDriver(): string
|
|
||||||
{
|
|
||||||
return Finding::query()->getConnection()->getDriverName();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function staleThreshold(): CarbonImmutable
|
|
||||||
{
|
|
||||||
return CarbonImmutable::now()->subDays(self::STALE_IN_PROGRESS_WINDOW_DAYS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,389 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace App\Services\Findings;
|
|
||||||
|
|
||||||
use App\Models\AlertRule;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Notifications\Findings\FindingEventNotification;
|
|
||||||
use App\Services\Alerts\AlertDispatchService;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use Carbon\CarbonInterface;
|
|
||||||
use InvalidArgumentException;
|
|
||||||
|
|
||||||
final class FindingNotificationService
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly AlertDispatchService $alertDispatchService,
|
|
||||||
private readonly CapabilityResolver $capabilityResolver,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @return array{
|
|
||||||
* event_type: string,
|
|
||||||
* fingerprint_key: string,
|
|
||||||
* direct_delivery_status: 'sent'|'suppressed'|'deduped'|'no_recipient',
|
|
||||||
* external_delivery_count: int
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function dispatch(Finding $finding, string $eventType, array $context = []): array
|
|
||||||
{
|
|
||||||
$finding = $this->reloadFinding($finding);
|
|
||||||
$tenant = $finding->tenant;
|
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
|
||||||
return $this->dispatchResult(
|
|
||||||
eventType: $eventType,
|
|
||||||
fingerprintKey: '',
|
|
||||||
directDeliveryStatus: 'no_recipient',
|
|
||||||
externalDeliveryCount: 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->shouldSuppressEvent($finding, $eventType, $context)) {
|
|
||||||
return $this->dispatchResult(
|
|
||||||
eventType: $eventType,
|
|
||||||
fingerprintKey: $this->fingerprintFor($finding, $eventType, $context),
|
|
||||||
directDeliveryStatus: 'suppressed',
|
|
||||||
externalDeliveryCount: 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolution = $this->resolveRecipient($finding, $eventType, $context);
|
|
||||||
$event = $this->buildEventEnvelope($finding, $tenant, $eventType, $resolution['reason'], $context);
|
|
||||||
|
|
||||||
$directDeliveryStatus = $this->dispatchDirectNotification($finding, $tenant, $event, $resolution['user_id']);
|
|
||||||
$externalDeliveryCount = $this->dispatchExternalCopies($finding, $event);
|
|
||||||
|
|
||||||
return $this->dispatchResult(
|
|
||||||
eventType: $eventType,
|
|
||||||
fingerprintKey: (string) $event['fingerprint_key'],
|
|
||||||
directDeliveryStatus: $directDeliveryStatus,
|
|
||||||
externalDeliveryCount: $externalDeliveryCount,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @return array{user_id: ?int, reason: ?string}
|
|
||||||
*/
|
|
||||||
private function resolveRecipient(Finding $finding, string $eventType, array $context): array
|
|
||||||
{
|
|
||||||
return match ($eventType) {
|
|
||||||
AlertRule::EVENT_FINDINGS_ASSIGNED => [
|
|
||||||
'user_id' => $this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id),
|
|
||||||
'reason' => 'new_assignee',
|
|
||||||
],
|
|
||||||
AlertRule::EVENT_FINDINGS_REOPENED => $this->preferredRecipient(
|
|
||||||
preferredUserId: $this->normalizeId($finding->assignee_user_id),
|
|
||||||
preferredReason: 'current_assignee',
|
|
||||||
fallbackUserId: $this->normalizeId($finding->owner_user_id),
|
|
||||||
fallbackReason: 'current_owner',
|
|
||||||
),
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON => $this->preferredRecipient(
|
|
||||||
preferredUserId: $this->normalizeId($finding->assignee_user_id),
|
|
||||||
preferredReason: 'current_assignee',
|
|
||||||
fallbackUserId: $this->normalizeId($finding->owner_user_id),
|
|
||||||
fallbackReason: 'current_owner',
|
|
||||||
),
|
|
||||||
AlertRule::EVENT_FINDINGS_OVERDUE => $this->preferredRecipient(
|
|
||||||
preferredUserId: $this->normalizeId($finding->owner_user_id),
|
|
||||||
preferredReason: 'current_owner',
|
|
||||||
fallbackUserId: $this->normalizeId($finding->assignee_user_id),
|
|
||||||
fallbackReason: 'current_assignee',
|
|
||||||
),
|
|
||||||
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function buildEventEnvelope(
|
|
||||||
Finding $finding,
|
|
||||||
Tenant $tenant,
|
|
||||||
string $eventType,
|
|
||||||
?string $recipientReason,
|
|
||||||
array $context,
|
|
||||||
): array {
|
|
||||||
$severity = strtolower(trim((string) $finding->severity));
|
|
||||||
$summary = $finding->resolvedSubjectDisplayName() ?? 'Finding #'.(int) $finding->getKey();
|
|
||||||
$title = $this->eventLabel($eventType);
|
|
||||||
$fingerprintKey = $this->fingerprintFor($finding, $eventType, $context);
|
|
||||||
$dueCycleKey = $this->dueCycleKey($finding, $eventType);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'event_type' => $eventType,
|
|
||||||
'workspace_id' => (int) $finding->workspace_id,
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'finding_id' => (int) $finding->getKey(),
|
|
||||||
'severity' => $severity,
|
|
||||||
'title' => $title,
|
|
||||||
'body' => $this->eventBody($eventType, $tenant, $summary, ucfirst($severity)),
|
|
||||||
'fingerprint_key' => $fingerprintKey,
|
|
||||||
'due_cycle_key' => $dueCycleKey,
|
|
||||||
'metadata' => [
|
|
||||||
'tenant_name' => $tenant->getFilamentName(),
|
|
||||||
'summary' => $summary,
|
|
||||||
'recipient_reason' => $recipientReason,
|
|
||||||
'owner_user_id' => $this->normalizeId($finding->owner_user_id),
|
|
||||||
'assignee_user_id' => $this->normalizeId($finding->assignee_user_id),
|
|
||||||
'due_at' => $this->optionalIso8601($finding->due_at),
|
|
||||||
'reopened_at' => $this->optionalIso8601($finding->reopened_at),
|
|
||||||
'severity_label' => ucfirst($severity),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $event
|
|
||||||
*/
|
|
||||||
private function dispatchDirectNotification(Finding $finding, Tenant $tenant, array $event, ?int $userId): string
|
|
||||||
{
|
|
||||||
if (! is_int($userId) || $userId <= 0) {
|
|
||||||
return 'no_recipient';
|
|
||||||
}
|
|
||||||
|
|
||||||
$user = User::query()->find($userId);
|
|
||||||
|
|
||||||
if (! $user instanceof User) {
|
|
||||||
return 'no_recipient';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $user->canAccessTenant($tenant)) {
|
|
||||||
return 'suppressed';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_FINDINGS_VIEW)) {
|
|
||||||
return 'suppressed';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->alreadySentDirectNotification($user, (string) $event['fingerprint_key'])) {
|
|
||||||
return 'deduped';
|
|
||||||
}
|
|
||||||
|
|
||||||
$user->notify(new FindingEventNotification($finding, $tenant, $event));
|
|
||||||
|
|
||||||
return 'sent';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $event
|
|
||||||
*/
|
|
||||||
private function dispatchExternalCopies(Finding $finding, array $event): int
|
|
||||||
{
|
|
||||||
$workspace = Workspace::query()->whereKey((int) $finding->workspace_id)->first();
|
|
||||||
|
|
||||||
if (! $workspace instanceof Workspace) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->alertDispatchService->dispatchEvent($workspace, $event);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function alreadySentDirectNotification(User $user, string $fingerprintKey): bool
|
|
||||||
{
|
|
||||||
if ($fingerprintKey === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $user->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->where('data->finding_event->fingerprint_key', $fingerprintKey)
|
|
||||||
->exists();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function reloadFinding(Finding $finding): Finding
|
|
||||||
{
|
|
||||||
$fresh = Finding::query()
|
|
||||||
->with('tenant')
|
|
||||||
->withSubjectDisplayName()
|
|
||||||
->find($finding->getKey());
|
|
||||||
|
|
||||||
if ($fresh instanceof Finding) {
|
|
||||||
return $fresh;
|
|
||||||
}
|
|
||||||
|
|
||||||
$finding->loadMissing('tenant');
|
|
||||||
|
|
||||||
return $finding;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
private function shouldSuppressEvent(Finding $finding, string $eventType, array $context): bool
|
|
||||||
{
|
|
||||||
return match ($eventType) {
|
|
||||||
AlertRule::EVENT_FINDINGS_ASSIGNED => ! $finding->hasOpenStatus()
|
|
||||||
|| $this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id) === null,
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON, AlertRule::EVENT_FINDINGS_OVERDUE => ! $finding->hasOpenStatus()
|
|
||||||
|| ! $finding->due_at instanceof CarbonInterface,
|
|
||||||
default => false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $context
|
|
||||||
*/
|
|
||||||
private function fingerprintFor(Finding $finding, string $eventType, array $context): string
|
|
||||||
{
|
|
||||||
$findingId = (int) $finding->getKey();
|
|
||||||
|
|
||||||
return match ($eventType) {
|
|
||||||
AlertRule::EVENT_FINDINGS_ASSIGNED => sprintf(
|
|
||||||
'finding:%d:%s:assignee:%d:updated:%s',
|
|
||||||
$findingId,
|
|
||||||
$eventType,
|
|
||||||
$this->normalizeId($context['assignee_user_id'] ?? $finding->assignee_user_id) ?? 0,
|
|
||||||
$this->optionalIso8601($finding->updated_at) ?? 'none',
|
|
||||||
),
|
|
||||||
AlertRule::EVENT_FINDINGS_REOPENED => sprintf(
|
|
||||||
'finding:%d:%s:reopened:%s',
|
|
||||||
$findingId,
|
|
||||||
$eventType,
|
|
||||||
$this->optionalIso8601($finding->reopened_at) ?? 'none',
|
|
||||||
),
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON,
|
|
||||||
AlertRule::EVENT_FINDINGS_OVERDUE => sprintf(
|
|
||||||
'finding:%d:%s:due:%s',
|
|
||||||
$findingId,
|
|
||||||
$eventType,
|
|
||||||
$this->dueCycleKey($finding, $eventType) ?? 'none',
|
|
||||||
),
|
|
||||||
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function dueCycleKey(Finding $finding, string $eventType): ?string
|
|
||||||
{
|
|
||||||
if (! in_array($eventType, [
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON,
|
|
||||||
AlertRule::EVENT_FINDINGS_OVERDUE,
|
|
||||||
], true)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->optionalIso8601($finding->due_at);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function eventLabel(string $eventType): string
|
|
||||||
{
|
|
||||||
return match ($eventType) {
|
|
||||||
AlertRule::EVENT_FINDINGS_ASSIGNED => 'Finding assigned',
|
|
||||||
AlertRule::EVENT_FINDINGS_REOPENED => 'Finding reopened',
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON => 'Finding due soon',
|
|
||||||
AlertRule::EVENT_FINDINGS_OVERDUE => 'Finding overdue',
|
|
||||||
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function eventBody(string $eventType, Tenant $tenant, string $summary, string $severityLabel): string
|
|
||||||
{
|
|
||||||
return match ($eventType) {
|
|
||||||
AlertRule::EVENT_FINDINGS_ASSIGNED => sprintf(
|
|
||||||
'%s in %s was assigned. %s severity.',
|
|
||||||
$summary,
|
|
||||||
$tenant->getFilamentName(),
|
|
||||||
$severityLabel,
|
|
||||||
),
|
|
||||||
AlertRule::EVENT_FINDINGS_REOPENED => sprintf(
|
|
||||||
'%s in %s reopened and needs follow-up. %s severity.',
|
|
||||||
$summary,
|
|
||||||
$tenant->getFilamentName(),
|
|
||||||
$severityLabel,
|
|
||||||
),
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON => sprintf(
|
|
||||||
'%s in %s is due within 24 hours. %s severity.',
|
|
||||||
$summary,
|
|
||||||
$tenant->getFilamentName(),
|
|
||||||
$severityLabel,
|
|
||||||
),
|
|
||||||
AlertRule::EVENT_FINDINGS_OVERDUE => sprintf(
|
|
||||||
'%s in %s is overdue. %s severity.',
|
|
||||||
$summary,
|
|
||||||
$tenant->getFilamentName(),
|
|
||||||
$severityLabel,
|
|
||||||
),
|
|
||||||
default => throw new InvalidArgumentException(sprintf('Unsupported finding notification event [%s].', $eventType)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{user_id: ?int, reason: ?string}
|
|
||||||
*/
|
|
||||||
private function preferredRecipient(
|
|
||||||
?int $preferredUserId,
|
|
||||||
string $preferredReason,
|
|
||||||
?int $fallbackUserId,
|
|
||||||
string $fallbackReason,
|
|
||||||
): array {
|
|
||||||
if (is_int($preferredUserId) && $preferredUserId > 0) {
|
|
||||||
return [
|
|
||||||
'user_id' => $preferredUserId,
|
|
||||||
'reason' => $preferredReason,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_int($fallbackUserId) && $fallbackUserId > 0) {
|
|
||||||
return [
|
|
||||||
'user_id' => $fallbackUserId,
|
|
||||||
'reason' => $fallbackReason,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'user_id' => null,
|
|
||||||
'reason' => null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizeId(mixed $value): ?int
|
|
||||||
{
|
|
||||||
if (! is_numeric($value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$normalized = (int) $value;
|
|
||||||
|
|
||||||
return $normalized > 0 ? $normalized : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function optionalIso8601(mixed $value): ?string
|
|
||||||
{
|
|
||||||
if (! $value instanceof CarbonInterface) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $value->toIso8601String();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* event_type: string,
|
|
||||||
* fingerprint_key: string,
|
|
||||||
* direct_delivery_status: 'sent'|'suppressed'|'deduped'|'no_recipient',
|
|
||||||
* external_delivery_count: int
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private function dispatchResult(
|
|
||||||
string $eventType,
|
|
||||||
string $fingerprintKey,
|
|
||||||
string $directDeliveryStatus,
|
|
||||||
int $externalDeliveryCount,
|
|
||||||
): array {
|
|
||||||
return [
|
|
||||||
'event_type' => $eventType,
|
|
||||||
'fingerprint_key' => $fingerprintKey,
|
|
||||||
'direct_delivery_status' => $directDeliveryStatus,
|
|
||||||
'external_delivery_count' => $externalDeliveryCount,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,7 +5,6 @@
|
|||||||
namespace App\Services\Findings;
|
namespace App\Services\Findings;
|
||||||
|
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\AlertRule;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\TenantMembership;
|
use App\Models\TenantMembership;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -18,7 +17,6 @@
|
|||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
final class FindingWorkflowService
|
final class FindingWorkflowService
|
||||||
@ -27,21 +25,8 @@ public function __construct(
|
|||||||
private readonly FindingSlaPolicy $slaPolicy,
|
private readonly FindingSlaPolicy $slaPolicy,
|
||||||
private readonly AuditLogger $auditLogger,
|
private readonly AuditLogger $auditLogger,
|
||||||
private readonly CapabilityResolver $capabilityResolver,
|
private readonly CapabilityResolver $capabilityResolver,
|
||||||
private readonly FindingNotificationService $findingNotificationService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, string>
|
|
||||||
*/
|
|
||||||
public static function meaningfulActivityActionValues(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
AuditActionId::FindingAssigned->value,
|
|
||||||
AuditActionId::FindingInProgress->value,
|
|
||||||
AuditActionId::FindingReopened->value,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
|
public function triage(Finding $finding, Tenant $tenant, User $actor): Finding
|
||||||
{
|
{
|
||||||
$this->authorize($finding, $tenant, $actor, [
|
$this->authorize($finding, $tenant, $actor, [
|
||||||
@ -122,7 +107,6 @@ public function assign(
|
|||||||
throw new InvalidArgumentException('Only open findings can be assigned.');
|
throw new InvalidArgumentException('Only open findings can be assigned.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$beforeAssigneeUserId = is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null;
|
|
||||||
$this->assertTenantMemberOrNull($tenant, $assigneeUserId, 'assignee_user_id');
|
$this->assertTenantMemberOrNull($tenant, $assigneeUserId, 'assignee_user_id');
|
||||||
$this->assertTenantMemberOrNull($tenant, $ownerUserId, 'owner_user_id');
|
$this->assertTenantMemberOrNull($tenant, $ownerUserId, 'owner_user_id');
|
||||||
|
|
||||||
@ -139,7 +123,7 @@ public function assign(
|
|||||||
afterAssigneeUserId: $assigneeUserId,
|
afterAssigneeUserId: $assigneeUserId,
|
||||||
);
|
);
|
||||||
|
|
||||||
$updatedFinding = $this->mutateAndAudit(
|
return $this->mutateAndAudit(
|
||||||
finding: $finding,
|
finding: $finding,
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
actor: $actor,
|
actor: $actor,
|
||||||
@ -157,63 +141,6 @@ public function assign(
|
|||||||
$record->owner_user_id = $ownerUserId;
|
$record->owner_user_id = $ownerUserId;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($assigneeUserId !== null && $assigneeUserId !== $beforeAssigneeUserId) {
|
|
||||||
$this->findingNotificationService->dispatch(
|
|
||||||
$updatedFinding,
|
|
||||||
AlertRule::EVENT_FINDINGS_ASSIGNED,
|
|
||||||
['assignee_user_id' => $assigneeUserId],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $updatedFinding;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function claim(Finding $finding, Tenant $tenant, User $actor): Finding
|
|
||||||
{
|
|
||||||
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_ASSIGN]);
|
|
||||||
|
|
||||||
$assigneeUserId = (int) $actor->getKey();
|
|
||||||
$ownerUserId = is_numeric($finding->owner_user_id) ? (int) $finding->owner_user_id : null;
|
|
||||||
$changeClassification = $this->responsibilityChangeClassification(
|
|
||||||
beforeOwnerUserId: $ownerUserId,
|
|
||||||
beforeAssigneeUserId: is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null,
|
|
||||||
afterOwnerUserId: $ownerUserId,
|
|
||||||
afterAssigneeUserId: $assigneeUserId,
|
|
||||||
);
|
|
||||||
$changeSummary = $this->responsibilityChangeSummary(
|
|
||||||
beforeOwnerUserId: $ownerUserId,
|
|
||||||
beforeAssigneeUserId: is_numeric($finding->assignee_user_id) ? (int) $finding->assignee_user_id : null,
|
|
||||||
afterOwnerUserId: $ownerUserId,
|
|
||||||
afterAssigneeUserId: $assigneeUserId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->mutateAndAudit(
|
|
||||||
finding: $finding,
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: $actor,
|
|
||||||
action: AuditActionId::FindingAssigned,
|
|
||||||
context: [
|
|
||||||
'metadata' => [
|
|
||||||
'assignee_user_id' => $assigneeUserId,
|
|
||||||
'owner_user_id' => $ownerUserId,
|
|
||||||
'responsibility_change_classification' => $changeClassification,
|
|
||||||
'responsibility_change_summary' => $changeSummary,
|
|
||||||
'claim_self_service' => true,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
mutate: function (Finding $record) use ($assigneeUserId): void {
|
|
||||||
if (! in_array((string) $record->status, Finding::openStatuses(), true)) {
|
|
||||||
throw new ConflictHttpException('Finding is no longer claimable.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($record->assignee_user_id !== null) {
|
|
||||||
throw new ConflictHttpException('Finding is already assigned.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$record->assignee_user_id = $assigneeUserId;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function responsibilityChangeClassification(
|
public function responsibilityChangeClassification(
|
||||||
@ -466,7 +393,7 @@ public function reopenBySystem(
|
|||||||
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
|
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
|
||||||
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt);
|
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt);
|
||||||
|
|
||||||
$reopenedFinding = $this->mutateAndAudit(
|
return $this->mutateAndAudit(
|
||||||
finding: $finding,
|
finding: $finding,
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
actor: null,
|
actor: null,
|
||||||
@ -497,30 +424,6 @@ public function reopenBySystem(
|
|||||||
actorType: AuditActorType::System,
|
actorType: AuditActorType::System,
|
||||||
operationRunId: $operationRunId,
|
operationRunId: $operationRunId,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->findingNotificationService->dispatch($reopenedFinding, AlertRule::EVENT_FINDINGS_REOPENED);
|
|
||||||
|
|
||||||
return $reopenedFinding;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function lastMeaningfulActivityAt(Finding $finding, mixed $latestWorkflowAuditAt = null): ?CarbonImmutable
|
|
||||||
{
|
|
||||||
$timestamps = array_filter([
|
|
||||||
$this->normalizeActivityTimestamp($finding->in_progress_at),
|
|
||||||
$this->normalizeActivityTimestamp($finding->reopened_at),
|
|
||||||
$this->normalizeActivityTimestamp($latestWorkflowAuditAt),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($timestamps === []) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
usort(
|
|
||||||
$timestamps,
|
|
||||||
static fn (CarbonImmutable $left, CarbonImmutable $right): int => $left->greaterThan($right) ? -1 : ($left->equalTo($right) ? 0 : 1),
|
|
||||||
);
|
|
||||||
|
|
||||||
return $timestamps[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -589,27 +492,6 @@ private function validatedReason(string $reason, string $field): string
|
|||||||
return $reason;
|
return $reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function normalizeActivityTimestamp(mixed $value): ?CarbonImmutable
|
|
||||||
{
|
|
||||||
if ($value instanceof CarbonImmutable) {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($value instanceof \DateTimeInterface) {
|
|
||||||
return CarbonImmutable::instance($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_string($value) || trim($value) === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return CarbonImmutable::parse($value);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $context
|
* @param array<string, mixed> $context
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -94,7 +94,6 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
}
|
}
|
||||||
|
|
||||||
$assignment = BaselineTenantAssignment::query()
|
$assignment = BaselineTenantAssignment::query()
|
||||||
->with('baselineProfile')
|
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@ -76,7 +76,7 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
return $next($request);
|
return $next($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array($path, ['/admin/findings/my-work', '/admin/findings/intake', '/admin/findings/hygiene'], true)) {
|
if ($path === '/admin/findings/my-work') {
|
||||||
$this->configureNavigationForRequest($panel);
|
$this->configureNavigationForRequest($panel);
|
||||||
|
|
||||||
return $next($request);
|
return $next($request);
|
||||||
@ -119,7 +119,7 @@ public function handle(Request $request, Closure $next): Response
|
|||||||
str_starts_with($path, '/admin/w/')
|
str_starts_with($path, '/admin/w/')
|
||||||
|| str_starts_with($path, '/admin/workspaces')
|
|| str_starts_with($path, '/admin/workspaces')
|
||||||
|| str_starts_with($path, '/admin/operations')
|
|| str_starts_with($path, '/admin/operations')
|
||||||
|| in_array($path, ['/admin', '/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding', '/admin/settings/workspace', '/admin/findings/my-work', '/admin/findings/intake', '/admin/findings/hygiene'], true)
|
|| in_array($path, ['/admin', '/admin/choose-workspace', '/admin/choose-tenant', '/admin/no-access', '/admin/alerts', '/admin/audit-log', '/admin/onboarding', '/admin/settings/workspace', '/admin/findings/my-work'], true)
|
||||||
) {
|
) {
|
||||||
$this->configureNavigationForRequest($panel);
|
$this->configureNavigationForRequest($panel);
|
||||||
|
|
||||||
@ -261,14 +261,6 @@ private function adminPathRequiresTenantSelection(string $path): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_starts_with($path, '/admin/findings/intake')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (str_starts_with($path, '/admin/findings/hygiene')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1;
|
return preg_match('#^/admin/(inventory|policies|policy-versions|backup-sets|backup-schedules|findings|finding-exceptions)(/|$)#', $path) === 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -294,8 +294,6 @@ private static function operationAliases(): array
|
|||||||
new OperationTypeAlias('policy_version.force_delete', 'policy_version.force_delete', 'canonical', true),
|
new OperationTypeAlias('policy_version.force_delete', 'policy_version.force_delete', 'canonical', true),
|
||||||
new OperationTypeAlias('alerts.evaluate', 'alerts.evaluate', 'canonical', true),
|
new OperationTypeAlias('alerts.evaluate', 'alerts.evaluate', 'canonical', true),
|
||||||
new OperationTypeAlias('alerts.deliver', 'alerts.deliver', 'canonical', true),
|
new OperationTypeAlias('alerts.deliver', 'alerts.deliver', 'canonical', true),
|
||||||
new OperationTypeAlias('baseline.capture', 'baseline.capture', 'canonical', true),
|
|
||||||
new OperationTypeAlias('baseline.compare', 'baseline.compare', 'canonical', true),
|
|
||||||
new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', true, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'),
|
new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', true, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'),
|
||||||
new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', true, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'),
|
new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', true, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'),
|
||||||
new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', true, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'),
|
new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', true, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'),
|
||||||
|
|||||||
@ -4,11 +4,7 @@
|
|||||||
|
|
||||||
namespace App\Support\OpsUx;
|
namespace App\Support\OpsUx;
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\AlertRule;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Support\OperationCatalog;
|
use App\Support\OperationCatalog;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -16,13 +12,11 @@
|
|||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||||
use App\Support\RedactionIntegrity;
|
use App\Support\RedactionIntegrity;
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
|
||||||
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||||
use App\Support\Ui\DerivedState\DerivedStateKey;
|
use App\Support\Ui\DerivedState\DerivedStateKey;
|
||||||
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
use Filament\Notifications\Notification as FilamentNotification;
|
||||||
|
|
||||||
final class OperationUxPresenter
|
final class OperationUxPresenter
|
||||||
@ -87,48 +81,6 @@ public static function scopeBusyToast(
|
|||||||
->duration(self::QUEUED_TOAST_DURATION_MS);
|
->duration(self::QUEUED_TOAST_DURATION_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $event
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function findingDatabaseNotificationMessage(Finding $finding, Tenant $tenant, array $event): array
|
|
||||||
{
|
|
||||||
return self::databaseNotificationMessage(
|
|
||||||
title: self::findingNotificationTitle($event),
|
|
||||||
body: self::findingNotificationBody($event),
|
|
||||||
status: self::findingNotificationStatus($event),
|
|
||||||
actionName: 'open_finding',
|
|
||||||
actionLabel: 'Open finding',
|
|
||||||
actionUrl: FindingResource::getUrl(
|
|
||||||
'view',
|
|
||||||
['record' => $finding],
|
|
||||||
panel: 'tenant',
|
|
||||||
tenant: $tenant,
|
|
||||||
),
|
|
||||||
actionTarget: 'finding_detail',
|
|
||||||
supportingLines: self::findingNotificationSupportingLines($event),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public static function queuedDatabaseNotificationMessage(OperationRun $run, object $notifiable): array
|
|
||||||
{
|
|
||||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
|
||||||
$primaryAction = self::operationRunPrimaryAction($run, $notifiable);
|
|
||||||
|
|
||||||
return self::databaseNotificationMessage(
|
|
||||||
title: "{$operationLabel} queued",
|
|
||||||
body: 'Queued for execution. Open the operation for progress and next steps.',
|
|
||||||
status: 'info',
|
|
||||||
actionName: 'view_run',
|
|
||||||
actionLabel: $primaryAction['label'],
|
|
||||||
actionUrl: $primaryAction['url'],
|
|
||||||
actionTarget: $primaryAction['target'],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Terminal DB notification payload.
|
* Terminal DB notification payload.
|
||||||
*
|
*
|
||||||
@ -137,40 +89,44 @@ public static function queuedDatabaseNotificationMessage(OperationRun $run, obje
|
|||||||
*/
|
*/
|
||||||
public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $tenant = null): FilamentNotification
|
public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $tenant = null): FilamentNotification
|
||||||
{
|
{
|
||||||
$payload = self::terminalNotificationPayload($run);
|
$operationLabel = OperationCatalog::label((string) $run->type);
|
||||||
$actionUrl = $tenant instanceof Tenant
|
$presentation = self::terminalPresentation($run);
|
||||||
? OperationRunUrl::view($run, $tenant)
|
$bodyLines = [$presentation['body']];
|
||||||
: OperationRunLinks::tenantlessView($run);
|
|
||||||
|
|
||||||
return self::makeDatabaseNotification(
|
$failureMessage = self::surfaceFailureDetail($run);
|
||||||
title: $payload['title'],
|
if ($failureMessage !== null) {
|
||||||
body: $payload['body'],
|
$bodyLines[] = $failureMessage;
|
||||||
status: $payload['status'],
|
|
||||||
actionName: 'view_run',
|
|
||||||
actionLabel: OperationRunLinks::openLabel(),
|
|
||||||
actionUrl: $actionUrl,
|
|
||||||
supportingLines: $payload['supportingLines'],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
$guidance = self::surfaceGuidance($run);
|
||||||
* @return array<string, mixed>
|
if ($guidance !== null) {
|
||||||
*/
|
$bodyLines[] = $guidance;
|
||||||
public static function terminalDatabaseNotificationMessage(OperationRun $run, object $notifiable): array
|
}
|
||||||
{
|
|
||||||
$payload = self::terminalNotificationPayload($run);
|
|
||||||
$primaryAction = self::operationRunPrimaryAction($run, $notifiable);
|
|
||||||
|
|
||||||
return self::databaseNotificationMessage(
|
$summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []);
|
||||||
title: $payload['title'],
|
if ($summary !== null) {
|
||||||
body: $payload['body'],
|
$bodyLines[] = $summary;
|
||||||
status: $payload['status'],
|
}
|
||||||
actionName: 'view_run',
|
|
||||||
actionLabel: $primaryAction['label'],
|
$integritySummary = RedactionIntegrity::noteForRun($run);
|
||||||
actionUrl: $primaryAction['url'],
|
if (is_string($integritySummary) && trim($integritySummary) !== '') {
|
||||||
actionTarget: $primaryAction['target'],
|
$bodyLines[] = trim($integritySummary);
|
||||||
supportingLines: $payload['supportingLines'],
|
}
|
||||||
);
|
|
||||||
|
$notification = FilamentNotification::make()
|
||||||
|
->title("{$operationLabel} {$presentation['titleSuffix']}")
|
||||||
|
->body(implode("\n", $bodyLines))
|
||||||
|
->status($presentation['status']);
|
||||||
|
|
||||||
|
if ($tenant instanceof Tenant) {
|
||||||
|
$notification->actions([
|
||||||
|
\Filament\Actions\Action::make('view')
|
||||||
|
->label(OperationRunLinks::openLabel())
|
||||||
|
->url(OperationRunUrl::view($run, $tenant)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $notification;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function surfaceGuidance(OperationRun $run): ?string
|
public static function surfaceGuidance(OperationRun $run): ?string
|
||||||
@ -389,59 +345,6 @@ private static function buildLifecycleAttentionSummary(OperationRun $run): ?stri
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $event
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function findingNotificationSupportingLines(array $event): array
|
|
||||||
{
|
|
||||||
$recipientReason = self::findingRecipientReasonCopy((string) data_get($event, 'metadata.recipient_reason', ''));
|
|
||||||
|
|
||||||
return $recipientReason !== '' ? [$recipientReason] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $event
|
|
||||||
*/
|
|
||||||
private static function findingNotificationTitle(array $event): string
|
|
||||||
{
|
|
||||||
$title = trim((string) ($event['title'] ?? 'Finding update'));
|
|
||||||
|
|
||||||
return $title !== '' ? $title : 'Finding update';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $event
|
|
||||||
*/
|
|
||||||
private static function findingNotificationBody(array $event): string
|
|
||||||
{
|
|
||||||
$body = trim((string) ($event['body'] ?? 'A finding needs follow-up.'));
|
|
||||||
|
|
||||||
return $body !== '' ? $body : 'A finding needs follow-up.';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $event
|
|
||||||
*/
|
|
||||||
private static function findingNotificationStatus(array $event): string
|
|
||||||
{
|
|
||||||
return match ((string) ($event['event_type'] ?? '')) {
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON => 'warning',
|
|
||||||
AlertRule::EVENT_FINDINGS_OVERDUE => 'danger',
|
|
||||||
default => 'info',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function findingRecipientReasonCopy(string $reason): string
|
|
||||||
{
|
|
||||||
return match ($reason) {
|
|
||||||
'new_assignee' => 'You are the new assignee.',
|
|
||||||
'current_assignee' => 'You are the current assignee.',
|
|
||||||
'current_owner' => 'You are the accountable owner.',
|
|
||||||
default => '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function governanceOperatorExplanation(OperationRun $run): ?OperatorExplanationPattern
|
public static function governanceOperatorExplanation(OperationRun $run): ?OperatorExplanationPattern
|
||||||
{
|
{
|
||||||
return self::resolveGovernanceOperatorExplanation($run);
|
return self::resolveGovernanceOperatorExplanation($run);
|
||||||
@ -474,7 +377,7 @@ private static function terminalPresentation(OperationRun $run): array
|
|||||||
if ($freshnessState->isReconciledFailed()) {
|
if ($freshnessState->isReconciledFailed()) {
|
||||||
return [
|
return [
|
||||||
'titleSuffix' => 'was automatically reconciled',
|
'titleSuffix' => 'was automatically reconciled',
|
||||||
'body' => 'Automatically reconciled after infrastructure failure.',
|
'body' => $reasonEnvelope?->operatorLabel ?? 'Automatically reconciled after infrastructure failure.',
|
||||||
'status' => 'danger',
|
'status' => 'danger',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -492,198 +395,17 @@ private static function terminalPresentation(OperationRun $run): array
|
|||||||
],
|
],
|
||||||
'blocked' => [
|
'blocked' => [
|
||||||
'titleSuffix' => 'blocked by prerequisite',
|
'titleSuffix' => 'blocked by prerequisite',
|
||||||
'body' => 'Blocked by prerequisite.',
|
'body' => $reasonEnvelope?->operatorLabel ?? 'Blocked by prerequisite.',
|
||||||
'status' => 'warning',
|
'status' => 'warning',
|
||||||
],
|
],
|
||||||
default => [
|
default => [
|
||||||
'titleSuffix' => 'execution failed',
|
'titleSuffix' => 'execution failed',
|
||||||
'body' => 'Execution failed.',
|
'body' => $reasonEnvelope?->operatorLabel ?? 'Execution failed.',
|
||||||
'status' => 'danger',
|
'status' => 'danger',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* title:string,
|
|
||||||
* body:string,
|
|
||||||
* status:string,
|
|
||||||
* supportingLines:list<string>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private static function terminalNotificationPayload(OperationRun $run): array
|
|
||||||
{
|
|
||||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
|
||||||
$presentation = self::terminalPresentation($run);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'title' => "{$operationLabel} {$presentation['titleSuffix']}",
|
|
||||||
'body' => $presentation['body'],
|
|
||||||
'status' => $presentation['status'],
|
|
||||||
'supportingLines' => self::terminalSupportingLines($run),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private static function terminalSupportingLines(OperationRun $run): array
|
|
||||||
{
|
|
||||||
$lines = [];
|
|
||||||
$reasonLabel = trim((string) (self::reasonEnvelope($run)?->operatorLabel ?? ''));
|
|
||||||
|
|
||||||
if ($reasonLabel !== '') {
|
|
||||||
$lines[] = $reasonLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
$failureMessage = self::surfaceFailureDetail($run);
|
|
||||||
|
|
||||||
if ($failureMessage !== null) {
|
|
||||||
$lines[] = $failureMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
$guidance = self::surfaceGuidance($run);
|
|
||||||
if ($guidance !== null) {
|
|
||||||
$lines[] = $guidance;
|
|
||||||
}
|
|
||||||
|
|
||||||
$summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []);
|
|
||||||
if ($summary !== null) {
|
|
||||||
$lines[] = $summary;
|
|
||||||
}
|
|
||||||
|
|
||||||
$integritySummary = RedactionIntegrity::noteForRun($run);
|
|
||||||
if (is_string($integritySummary) && trim($integritySummary) !== '') {
|
|
||||||
$lines[] = trim($integritySummary);
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_filter($lines, static fn (string $line): bool => trim($line) !== ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{label:string, url:?string, target:string}
|
|
||||||
*/
|
|
||||||
private static function operationRunPrimaryAction(OperationRun $run, object $notifiable): array
|
|
||||||
{
|
|
||||||
if ($notifiable instanceof PlatformUser) {
|
|
||||||
return [
|
|
||||||
'label' => OperationRunLinks::openLabel(),
|
|
||||||
'url' => SystemOperationRunLinks::view($run),
|
|
||||||
'target' => 'system_operation_run',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (self::isManagedTenantOnboardingWizardRun($run)) {
|
|
||||||
return [
|
|
||||||
'label' => OperationRunLinks::openLabel(),
|
|
||||||
'url' => OperationRunLinks::tenantlessView($run),
|
|
||||||
'target' => 'tenantless_operation_run',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($run->tenant instanceof Tenant) {
|
|
||||||
return [
|
|
||||||
'label' => OperationRunLinks::openLabel(),
|
|
||||||
'url' => OperationRunLinks::view($run, $run->tenant),
|
|
||||||
'target' => 'admin_operation_run',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'label' => OperationRunLinks::openLabel(),
|
|
||||||
'url' => OperationRunLinks::tenantlessView($run),
|
|
||||||
'target' => 'tenantless_operation_run',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function isManagedTenantOnboardingWizardRun(OperationRun $run): bool
|
|
||||||
{
|
|
||||||
$context = is_array($run->context) ? $run->context : [];
|
|
||||||
$wizard = $context['wizard'] ?? null;
|
|
||||||
|
|
||||||
return is_array($wizard)
|
|
||||||
&& ($wizard['flow'] ?? null) === 'managed_tenant_onboarding';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $supportingLines
|
|
||||||
*/
|
|
||||||
private static function makeDatabaseNotification(
|
|
||||||
string $title,
|
|
||||||
string $body,
|
|
||||||
string $status,
|
|
||||||
string $actionName,
|
|
||||||
string $actionLabel,
|
|
||||||
?string $actionUrl,
|
|
||||||
array $supportingLines = [],
|
|
||||||
): FilamentNotification {
|
|
||||||
return FilamentNotification::make()
|
|
||||||
->title($title)
|
|
||||||
->body(self::composeDatabaseNotificationBody($body, $supportingLines))
|
|
||||||
->status($status)
|
|
||||||
->actions([
|
|
||||||
Action::make($actionName)
|
|
||||||
->label($actionLabel)
|
|
||||||
->url($actionUrl),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $supportingLines
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private static function databaseNotificationMessage(
|
|
||||||
string $title,
|
|
||||||
string $body,
|
|
||||||
string $status,
|
|
||||||
string $actionName,
|
|
||||||
string $actionLabel,
|
|
||||||
?string $actionUrl,
|
|
||||||
string $actionTarget,
|
|
||||||
array $supportingLines = [],
|
|
||||||
): array {
|
|
||||||
$message = self::makeDatabaseNotification(
|
|
||||||
title: $title,
|
|
||||||
body: $body,
|
|
||||||
status: $status,
|
|
||||||
actionName: $actionName,
|
|
||||||
actionLabel: $actionLabel,
|
|
||||||
actionUrl: $actionUrl,
|
|
||||||
supportingLines: $supportingLines,
|
|
||||||
)->getDatabaseMessage();
|
|
||||||
|
|
||||||
$message['supporting_lines'] = array_values(array_filter(
|
|
||||||
$supportingLines,
|
|
||||||
static fn (string $line): bool => trim($line) !== '',
|
|
||||||
));
|
|
||||||
|
|
||||||
if (is_array($message['actions'][0] ?? null)) {
|
|
||||||
$message['actions'][0]['target'] = $actionTarget;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $message;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param list<string> $supportingLines
|
|
||||||
*/
|
|
||||||
private static function composeDatabaseNotificationBody(string $body, array $supportingLines): string
|
|
||||||
{
|
|
||||||
$lines = [trim($body)];
|
|
||||||
|
|
||||||
foreach ($supportingLines as $line) {
|
|
||||||
$line = trim($line);
|
|
||||||
|
|
||||||
if ($line === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$lines[] = $line;
|
|
||||||
}
|
|
||||||
|
|
||||||
return implode("\n", array_filter($lines, static fn (string $line): bool => $line !== ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function requiresFollowUp(OperationRun $run): bool
|
private static function requiresFollowUp(OperationRun $run): bool
|
||||||
{
|
{
|
||||||
if (self::firstNextStepLabel($run) !== null) {
|
if (self::firstNextStepLabel($run) !== null) {
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareLanding;
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
use App\Filament\Pages\ChooseTenant;
|
use App\Filament\Pages\ChooseTenant;
|
||||||
use App\Filament\Pages\Findings\FindingsHygieneReport;
|
|
||||||
use App\Filament\Pages\Findings\MyFindingsInbox;
|
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||||
use App\Filament\Pages\TenantDashboard;
|
use App\Filament\Pages\TenantDashboard;
|
||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
@ -21,7 +20,6 @@
|
|||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use App\Services\Auth\CapabilityResolver;
|
use App\Services\Auth\CapabilityResolver;
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||||
use App\Services\Findings\FindingAssignmentHygieneService;
|
|
||||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||||
@ -51,7 +49,6 @@ final class WorkspaceOverviewBuilder
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
private WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
||||||
private CapabilityResolver $capabilityResolver,
|
private CapabilityResolver $capabilityResolver,
|
||||||
private FindingAssignmentHygieneService $findingAssignmentHygieneService,
|
|
||||||
private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver,
|
private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver,
|
||||||
private TenantBackupHealthResolver $tenantBackupHealthResolver,
|
private TenantBackupHealthResolver $tenantBackupHealthResolver,
|
||||||
private RestoreSafetyResolver $restoreSafetyResolver,
|
private RestoreSafetyResolver $restoreSafetyResolver,
|
||||||
@ -137,7 +134,6 @@ public function build(Workspace $workspace, User $user): array
|
|||||||
];
|
];
|
||||||
|
|
||||||
$myFindingsSignal = $this->myFindingsSignal($workspaceId, $accessibleTenants, $user);
|
$myFindingsSignal = $this->myFindingsSignal($workspaceId, $accessibleTenants, $user);
|
||||||
$findingsHygieneSignal = $this->findingsHygieneSignal($workspace, $user);
|
|
||||||
|
|
||||||
$zeroTenantState = null;
|
$zeroTenantState = null;
|
||||||
|
|
||||||
@ -178,7 +174,6 @@ public function build(Workspace $workspace, User $user): array
|
|||||||
'workspace_name' => (string) $workspace->name,
|
'workspace_name' => (string) $workspace->name,
|
||||||
'accessible_tenant_count' => $accessibleTenants->count(),
|
'accessible_tenant_count' => $accessibleTenants->count(),
|
||||||
'my_findings_signal' => $myFindingsSignal,
|
'my_findings_signal' => $myFindingsSignal,
|
||||||
'findings_hygiene_signal' => $findingsHygieneSignal,
|
|
||||||
'summary_metrics' => $summaryMetrics,
|
'summary_metrics' => $summaryMetrics,
|
||||||
'triage_review_progress' => $triageReviewProgress['families'],
|
'triage_review_progress' => $triageReviewProgress['families'],
|
||||||
'attention_items' => $attentionItems,
|
'attention_items' => $attentionItems,
|
||||||
@ -222,26 +217,29 @@ private function myFindingsSignal(int $workspaceId, Collection $accessibleTenant
|
|||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
$assignedCounts = $visibleTenantIds === []
|
$openAssignedCount = $visibleTenantIds === []
|
||||||
? null
|
? 0
|
||||||
: $this->scopeToVisibleTenants(
|
: (int) $this->scopeToVisibleTenants(
|
||||||
Finding::query(),
|
Finding::query(),
|
||||||
$workspaceId,
|
$workspaceId,
|
||||||
$visibleTenantIds,
|
$visibleTenantIds,
|
||||||
)
|
)
|
||||||
->where('assignee_user_id', (int) $user->getKey())
|
->where('assignee_user_id', (int) $user->getKey())
|
||||||
->whereIn('status', Finding::openStatusesForQuery())
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
->selectRaw('count(*) as open_assigned_count')
|
->count();
|
||||||
->selectRaw('sum(case when due_at is not null and due_at < ? then 1 else 0 end) as overdue_assigned_count', [now()])
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$openAssignedCount = is_numeric($assignedCounts?->open_assigned_count)
|
$overdueAssignedCount = $visibleTenantIds === []
|
||||||
? (int) $assignedCounts->open_assigned_count
|
? 0
|
||||||
: 0;
|
: (int) $this->scopeToVisibleTenants(
|
||||||
|
Finding::query(),
|
||||||
$overdueAssignedCount = is_numeric($assignedCounts?->overdue_assigned_count)
|
$workspaceId,
|
||||||
? (int) $assignedCounts->overdue_assigned_count
|
$visibleTenantIds,
|
||||||
: 0;
|
)
|
||||||
|
->where('assignee_user_id', (int) $user->getKey())
|
||||||
|
->whereIn('status', Finding::openStatusesForQuery())
|
||||||
|
->whereNotNull('due_at')
|
||||||
|
->where('due_at', '<', now())
|
||||||
|
->count();
|
||||||
|
|
||||||
$isCalm = $openAssignedCount === 0;
|
$isCalm = $openAssignedCount === 0;
|
||||||
|
|
||||||
@ -268,66 +266,6 @@ private function myFindingsSignal(int $workspaceId, Collection $accessibleTenant
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
private function findingsHygieneSignal(Workspace $workspace, User $user): array
|
|
||||||
{
|
|
||||||
$summary = $this->findingAssignmentHygieneService->summary($workspace, $user);
|
|
||||||
$uniqueIssueCount = $summary['unique_issue_count'];
|
|
||||||
$brokenAssignmentCount = $summary['broken_assignment_count'];
|
|
||||||
$staleInProgressCount = $summary['stale_in_progress_count'];
|
|
||||||
$isCalm = $uniqueIssueCount === 0;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'headline' => $isCalm
|
|
||||||
? 'Findings hygiene is calm'
|
|
||||||
: sprintf(
|
|
||||||
'%d visible hygiene %s need follow-up',
|
|
||||||
$uniqueIssueCount,
|
|
||||||
Str::plural('issue', $uniqueIssueCount),
|
|
||||||
),
|
|
||||||
'description' => $this->findingsHygieneDescription($brokenAssignmentCount, $staleInProgressCount),
|
|
||||||
'unique_issue_count' => $uniqueIssueCount,
|
|
||||||
'broken_assignment_count' => $brokenAssignmentCount,
|
|
||||||
'stale_in_progress_count' => $staleInProgressCount,
|
|
||||||
'is_calm' => $isCalm,
|
|
||||||
'cta_label' => 'Open hygiene report',
|
|
||||||
'cta_url' => FindingsHygieneReport::getUrl(panel: 'admin'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function findingsHygieneDescription(int $brokenAssignmentCount, int $staleInProgressCount): string
|
|
||||||
{
|
|
||||||
if ($brokenAssignmentCount === 0 && $staleInProgressCount === 0) {
|
|
||||||
return 'No broken assignments or stale in-progress work are visible across your entitled tenants.';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($brokenAssignmentCount > 0 && $staleInProgressCount > 0) {
|
|
||||||
return sprintf(
|
|
||||||
'%d broken %s and %d stale in-progress %s need repair.',
|
|
||||||
$brokenAssignmentCount,
|
|
||||||
Str::plural('assignment', $brokenAssignmentCount),
|
|
||||||
$staleInProgressCount,
|
|
||||||
Str::plural('finding', $staleInProgressCount),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($brokenAssignmentCount > 0) {
|
|
||||||
return sprintf(
|
|
||||||
'%d broken %s need repair before work can continue.',
|
|
||||||
$brokenAssignmentCount,
|
|
||||||
Str::plural('assignment', $brokenAssignmentCount),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return sprintf(
|
|
||||||
'%d stale in-progress %s need follow-up.',
|
|
||||||
$staleInProgressCount,
|
|
||||||
Str::plural('finding', $staleInProgressCount),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param Collection<int, Tenant> $accessibleTenants
|
* @param Collection<int, Tenant> $accessibleTenants
|
||||||
* @return list<array<string, mixed>>
|
* @return list<array<string, mixed>>
|
||||||
@ -1496,9 +1434,10 @@ private function canManageWorkspaces(Workspace $workspace, User $user): bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
$roles = WorkspaceRoleCapabilityMap::rolesWithCapability(Capabilities::WORKSPACE_MEMBERSHIP_MANAGE);
|
||||||
$role = $this->workspaceCapabilityResolver->getRole($user, $workspace);
|
|
||||||
|
|
||||||
return $role !== null && in_array($role->value, $roles, true);
|
return $user->workspaceMemberships()
|
||||||
|
->whereIn('role', $roles)
|
||||||
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function tenantRouteKey(Tenant $tenant): string
|
private function tenantRouteKey(Tenant $tenant): string
|
||||||
|
|||||||
@ -140,34 +140,6 @@ public function reopened(): static
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function ownedBy(?int $userId): static
|
|
||||||
{
|
|
||||||
return $this->state(fn (array $attributes): array => [
|
|
||||||
'owner_user_id' => $userId,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function assignedTo(?int $userId): static
|
|
||||||
{
|
|
||||||
return $this->state(fn (array $attributes): array => [
|
|
||||||
'assignee_user_id' => $userId,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function dueWithinHours(int $hours): static
|
|
||||||
{
|
|
||||||
return $this->state(fn (array $attributes): array => [
|
|
||||||
'due_at' => now()->addHours($hours),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function overdueByHours(int $hours = 1): static
|
|
||||||
{
|
|
||||||
return $this->state(fn (array $attributes): array => [
|
|
||||||
'due_at' => now()->subHours($hours),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State for closed findings.
|
* State for closed findings.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,103 +0,0 @@
|
|||||||
<x-filament-panels::page>
|
|
||||||
@php($scope = $this->appliedScope())
|
|
||||||
@php($summary = $this->summaryCounts())
|
|
||||||
@php($reasonFilters = $this->availableReasonFilters())
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
<x-filament::section>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-danger-200 bg-danger-50 px-3 py-1 text-xs font-medium text-danger-700 dark:border-danger-700/60 dark:bg-danger-950/40 dark:text-danger-200">
|
|
||||||
<x-filament::icon icon="heroicon-o-wrench-screwdriver" class="h-3.5 w-3.5" />
|
|
||||||
Findings hygiene
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h1 class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
|
|
||||||
Findings hygiene report
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
|
||||||
Review visible broken assignments and stale in-progress work across entitled tenants in one read-first repair queue. Existing finding detail stays the only place where reassignment or lifecycle repair happens.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
|
||||||
<div class="text-xs font-medium uppercase tracking-[0.14em] text-gray-500 dark:text-gray-400">
|
|
||||||
Visible issues
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 text-3xl font-semibold text-gray-950 dark:text-white">
|
|
||||||
{{ $summary['unique_issue_count'] }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
One row per visible finding, even when multiple hygiene reasons apply.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-danger-200 bg-danger-50/70 p-4 shadow-sm dark:border-danger-700/50 dark:bg-danger-950/30">
|
|
||||||
<div class="text-xs font-medium uppercase tracking-[0.14em] text-danger-700 dark:text-danger-200">
|
|
||||||
Broken assignments
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 text-3xl font-semibold text-danger-950 dark:text-danger-100">
|
|
||||||
{{ $summary['broken_assignment_count'] }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-sm text-danger-800 dark:text-danger-200">
|
|
||||||
Assignees who can no longer act on the finding.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-warning-200 bg-warning-50/70 p-4 shadow-sm dark:border-warning-700/50 dark:bg-warning-950/30">
|
|
||||||
<div class="text-xs font-medium uppercase tracking-[0.14em] text-warning-700 dark:text-warning-200">
|
|
||||||
Stale in progress
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 text-3xl font-semibold text-warning-950 dark:text-warning-100">
|
|
||||||
{{ $summary['stale_in_progress_count'] }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-sm text-warning-800 dark:text-warning-200">
|
|
||||||
In-progress findings with no meaningful workflow movement for seven days.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
|
||||||
<div class="text-xs font-medium uppercase tracking-[0.14em] text-gray-500 dark:text-gray-400">
|
|
||||||
Applied scope
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 text-sm font-semibold text-gray-950 dark:text-white">
|
|
||||||
{{ $scope['reason_filter_label'] }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
@if (($scope['tenant_prefilter_source'] ?? 'none') === 'active_tenant_context')
|
|
||||||
Tenant prefilter from active context:
|
|
||||||
<span class="font-medium text-gray-950 dark:text-white">{{ $scope['tenant_label'] }}</span>
|
|
||||||
@elseif (($scope['tenant_prefilter_source'] ?? 'none') === 'explicit_filter')
|
|
||||||
Tenant filter applied:
|
|
||||||
<span class="font-medium text-gray-950 dark:text-white">{{ $scope['tenant_label'] }}</span>
|
|
||||||
@else
|
|
||||||
All visible tenants are currently included.
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
@foreach ($reasonFilters as $reasonFilter)
|
|
||||||
<a
|
|
||||||
href="{{ $reasonFilter['url'] }}"
|
|
||||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ $reasonFilter['active'] ? 'border-primary-200 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:text-gray-950 dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:hover:border-white/20 dark:hover:text-white' }}"
|
|
||||||
>
|
|
||||||
<span>{{ $reasonFilter['label'] }}</span>
|
|
||||||
<span class="rounded-full bg-black/5 px-2 py-0.5 text-xs font-semibold dark:bg-white/10">
|
|
||||||
{{ $reasonFilter['badge_count'] }}
|
|
||||||
</span>
|
|
||||||
<span class="text-[11px] uppercase tracking-[0.12em] opacity-70">Fixed</span>
|
|
||||||
</a>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
|
|
||||||
{{ $this->table }}
|
|
||||||
</div>
|
|
||||||
</x-filament-panels::page>
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
<x-filament-panels::page>
|
|
||||||
@php($scope = $this->appliedScope())
|
|
||||||
@php($summary = $this->summaryCounts())
|
|
||||||
@php($queueViews = $this->queueViews())
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
<x-filament::section>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-warning-200 bg-warning-50 px-3 py-1 text-xs font-medium text-warning-700 dark:border-warning-700/60 dark:bg-warning-950/40 dark:text-warning-300">
|
|
||||||
<x-filament::icon icon="heroicon-o-inbox-stack" class="h-3.5 w-3.5" />
|
|
||||||
Shared unassigned work
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h1 class="text-2xl font-semibold tracking-tight text-gray-950 dark:text-white">
|
|
||||||
Findings intake
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p class="max-w-3xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
|
||||||
Review visible unassigned open findings across entitled tenants in one queue. Tenant context can narrow the view, but the intake scope stays fixed.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
|
||||||
<div class="text-xs font-medium uppercase tracking-[0.14em] text-gray-500 dark:text-gray-400">
|
|
||||||
Visible unassigned
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 text-3xl font-semibold text-gray-950 dark:text-white">
|
|
||||||
{{ $summary['visible_unassigned'] }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
Visible unassigned intake rows after the current tenant scope.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-warning-200 bg-warning-50/70 p-4 shadow-sm dark:border-warning-700/50 dark:bg-warning-950/30">
|
|
||||||
<div class="text-xs font-medium uppercase tracking-[0.14em] text-warning-700 dark:text-warning-200">
|
|
||||||
Needs triage
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 text-3xl font-semibold text-warning-950 dark:text-warning-100">
|
|
||||||
{{ $summary['visible_needs_triage'] }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-sm text-warning-800 dark:text-warning-200">
|
|
||||||
Visible `new` and `reopened` intake rows that still need first routing.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-danger-200 bg-danger-50/70 p-4 shadow-sm dark:border-danger-700/50 dark:bg-danger-950/30">
|
|
||||||
<div class="text-xs font-medium uppercase tracking-[0.14em] text-danger-700 dark:text-danger-200">
|
|
||||||
Overdue
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 text-3xl font-semibold text-danger-950 dark:text-danger-100">
|
|
||||||
{{ $summary['visible_overdue'] }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-sm text-danger-800 dark:text-danger-200">
|
|
||||||
Intake rows that are already past due.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-white/5">
|
|
||||||
<div class="text-xs font-medium uppercase tracking-[0.14em] text-gray-500 dark:text-gray-400">
|
|
||||||
Applied scope
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 text-sm font-semibold text-gray-950 dark:text-white">
|
|
||||||
{{ $scope['queue_view_label'] }}
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-300">
|
|
||||||
@if (($scope['tenant_prefilter_source'] ?? 'none') === 'active_tenant_context')
|
|
||||||
Tenant prefilter from active context:
|
|
||||||
<span class="font-medium text-gray-950 dark:text-white">{{ $scope['tenant_label'] }}</span>
|
|
||||||
@elseif (($scope['tenant_prefilter_source'] ?? 'none') === 'explicit_filter')
|
|
||||||
Tenant filter applied:
|
|
||||||
<span class="font-medium text-gray-950 dark:text-white">{{ $scope['tenant_label'] }}</span>
|
|
||||||
@else
|
|
||||||
All visible tenants are currently included.
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
@foreach ($queueViews as $queueView)
|
|
||||||
<a
|
|
||||||
href="{{ $queueView['url'] }}"
|
|
||||||
class="inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition {{ $queueView['active'] ? 'border-primary-200 bg-primary-50 text-primary-700 dark:border-primary-700/60 dark:bg-primary-950/40 dark:text-primary-300' : 'border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:text-gray-950 dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:hover:border-white/20 dark:hover:text-white' }}"
|
|
||||||
>
|
|
||||||
<span>{{ $queueView['label'] }}</span>
|
|
||||||
<span class="rounded-full bg-black/5 px-2 py-0.5 text-xs font-semibold dark:bg-white/10">
|
|
||||||
{{ $queueView['badge_count'] }}
|
|
||||||
</span>
|
|
||||||
<span class="text-[11px] uppercase tracking-[0.12em] opacity-70">Fixed</span>
|
|
||||||
</a>
|
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</x-filament::section>
|
|
||||||
|
|
||||||
{{ $this->table }}
|
|
||||||
</div>
|
|
||||||
</x-filament-panels::page>
|
|
||||||
@ -3,7 +3,6 @@
|
|||||||
$workspace = $overview['workspace'] ?? ['name' => 'Workspace'];
|
$workspace = $overview['workspace'] ?? ['name' => 'Workspace'];
|
||||||
$quickActions = $overview['quick_actions'] ?? [];
|
$quickActions = $overview['quick_actions'] ?? [];
|
||||||
$myFindingsSignal = $overview['my_findings_signal'] ?? null;
|
$myFindingsSignal = $overview['my_findings_signal'] ?? null;
|
||||||
$findingsHygieneSignal = $overview['findings_hygiene_signal'] ?? null;
|
|
||||||
$zeroTenantState = $overview['zero_tenant_state'] ?? null;
|
$zeroTenantState = $overview['zero_tenant_state'] ?? null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@ -102,52 +101,6 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
|
|||||||
</section>
|
</section>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if (is_array($findingsHygieneSignal))
|
|
||||||
<section class="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-white/10 dark:bg-white/5">
|
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="inline-flex w-fit items-center gap-2 rounded-full border border-danger-200 bg-danger-50 px-3 py-1 text-xs font-medium text-danger-700 dark:border-danger-700/60 dark:bg-danger-950/40 dark:text-danger-200">
|
|
||||||
<x-filament::icon icon="heroicon-o-wrench-screwdriver" class="h-3.5 w-3.5" />
|
|
||||||
Findings hygiene
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h2 class="text-base font-semibold text-gray-950 dark:text-white">
|
|
||||||
{{ $findingsHygieneSignal['headline'] }}
|
|
||||||
</h2>
|
|
||||||
<p class="max-w-2xl text-sm leading-6 text-gray-600 dark:text-gray-300">
|
|
||||||
{{ $findingsHygieneSignal['description'] }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 text-xs">
|
|
||||||
<span class="inline-flex items-center rounded-full border border-gray-200 bg-gray-50 px-3 py-1 font-medium text-gray-700 dark:border-white/10 dark:bg-white/5 dark:text-gray-300">
|
|
||||||
Unique issues: {{ $findingsHygieneSignal['unique_issue_count'] }}
|
|
||||||
</span>
|
|
||||||
<span class="inline-flex items-center rounded-full border border-danger-200 bg-danger-50 px-3 py-1 font-medium text-danger-700 dark:border-danger-700/50 dark:bg-danger-950/30 dark:text-danger-200">
|
|
||||||
Broken assignments: {{ $findingsHygieneSignal['broken_assignment_count'] }}
|
|
||||||
</span>
|
|
||||||
<span class="inline-flex items-center rounded-full border border-warning-200 bg-warning-50 px-3 py-1 font-medium text-warning-700 dark:border-warning-700/50 dark:bg-warning-950/30 dark:text-warning-200">
|
|
||||||
Stale in progress: {{ $findingsHygieneSignal['stale_in_progress_count'] }}
|
|
||||||
</span>
|
|
||||||
<span class="inline-flex items-center rounded-full border px-3 py-1 font-medium {{ ($findingsHygieneSignal['is_calm'] ?? false) ? 'border-success-200 bg-success-50 text-success-700 dark:border-success-700/50 dark:bg-success-950/30 dark:text-success-200' : 'border-warning-200 bg-warning-50 text-warning-700 dark:border-warning-700/50 dark:bg-warning-950/30 dark:text-warning-200' }}">
|
|
||||||
{{ ($findingsHygieneSignal['is_calm'] ?? false) ? 'Calm' : 'Needs repair' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<x-filament::button
|
|
||||||
tag="a"
|
|
||||||
color="danger"
|
|
||||||
:href="$findingsHygieneSignal['cta_url']"
|
|
||||||
icon="heroicon-o-arrow-right"
|
|
||||||
>
|
|
||||||
{{ $findingsHygieneSignal['cta_label'] }}
|
|
||||||
</x-filament::button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if (is_array($zeroTenantState))
|
@if (is_array($zeroTenantState))
|
||||||
<section class="rounded-2xl border border-warning-200 bg-warning-50 p-5 shadow-sm dark:border-warning-700/50 dark:bg-warning-950/30">
|
<section class="rounded-2xl border border-warning-200 bg-warning-50 p-5 shadow-sm dark:border-warning-700/50 dark:bg-warning-950/30">
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
|||||||
@ -1,284 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Resources\AlertDeliveryResource;
|
|
||||||
use App\Filament\Resources\AlertDeliveryResource\Pages\ListAlertDeliveries;
|
|
||||||
use App\Filament\Resources\AlertRuleResource;
|
|
||||||
use App\Models\AlertDelivery;
|
|
||||||
use App\Models\AlertDestination;
|
|
||||||
use App\Models\AlertRule;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Notifications\Findings\FindingEventNotification;
|
|
||||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
|
||||||
use App\Services\Findings\FindingNotificationService;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
it('exposes the four finding notification events in the existing alert rule options', function (): void {
|
|
||||||
expect(AlertRuleResource::eventTypeOptions())->toMatchArray([
|
|
||||||
AlertRule::EVENT_FINDINGS_ASSIGNED => 'Finding assigned',
|
|
||||||
AlertRule::EVENT_FINDINGS_REOPENED => 'Finding reopened',
|
|
||||||
AlertRule::EVENT_FINDINGS_DUE_SOON => 'Finding due soon',
|
|
||||||
AlertRule::EVENT_FINDINGS_OVERDUE => 'Finding overdue',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('delivers a direct finding notification without requiring a matching alert rule', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
|
|
||||||
expect($result['direct_delivery_status'])->toBe('sent')
|
|
||||||
->and($result['external_delivery_count'])->toBe(0)
|
|
||||||
->and($assignee->notifications()->where('type', FindingEventNotification::class)->count())->toBe(1)
|
|
||||||
->and(AlertDelivery::query()->count())->toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fans out matching external copies through the existing alert delivery pipeline', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$workspaceId = (int) $tenant->workspace_id;
|
|
||||||
|
|
||||||
$destination = AlertDestination::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'is_enabled' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$rule = AlertRule::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE,
|
|
||||||
'minimum_severity' => Finding::SEVERITY_MEDIUM,
|
|
||||||
'is_enabled' => true,
|
|
||||||
'cooldown_seconds' => 0,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$rule->destinations()->attach($destination->getKey(), [
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => null,
|
|
||||||
'severity' => Finding::SEVERITY_HIGH,
|
|
||||||
'due_at' => now()->subHour(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
|
|
||||||
$delivery = AlertDelivery::query()->latest('id')->first();
|
|
||||||
|
|
||||||
expect($result['direct_delivery_status'])->toBe('sent')
|
|
||||||
->and($result['external_delivery_count'])->toBe(1)
|
|
||||||
->and($delivery)->not->toBeNull()
|
|
||||||
->and($delivery?->event_type)->toBe(AlertRule::EVENT_FINDINGS_OVERDUE)
|
|
||||||
->and(data_get($delivery?->payload, 'title'))->toBe('Finding overdue');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('inherits minimum severity tenant scoping and cooldown suppression for finding alert copies', function (): void {
|
|
||||||
[$ownerA, $tenantA] = createUserWithTenant(role: 'owner');
|
|
||||||
$workspaceId = (int) $tenantA->workspace_id;
|
|
||||||
|
|
||||||
$tenantB = Tenant::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
]);
|
|
||||||
[$ownerB] = createUserWithTenant(tenant: $tenantB, role: 'owner');
|
|
||||||
|
|
||||||
$destination = AlertDestination::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'is_enabled' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$rule = AlertRule::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE,
|
|
||||||
'minimum_severity' => Finding::SEVERITY_HIGH,
|
|
||||||
'tenant_scope_mode' => AlertRule::TENANT_SCOPE_ALLOWLIST,
|
|
||||||
'tenant_allowlist' => [(int) $tenantA->getKey()],
|
|
||||||
'is_enabled' => true,
|
|
||||||
'cooldown_seconds' => 3600,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$rule->destinations()->attach($destination->getKey(), [
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$mediumFinding = Finding::factory()->for($tenantA)->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'owner_user_id' => (int) $ownerA->getKey(),
|
|
||||||
'severity' => Finding::SEVERITY_MEDIUM,
|
|
||||||
'due_at' => now()->subHour(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$scopedOutFinding = Finding::factory()->for($tenantB)->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'owner_user_id' => (int) $ownerB->getKey(),
|
|
||||||
'severity' => Finding::SEVERITY_CRITICAL,
|
|
||||||
'due_at' => now()->subHour(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$trackedFinding = Finding::factory()->for($tenantA)->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'owner_user_id' => (int) $ownerA->getKey(),
|
|
||||||
'severity' => Finding::SEVERITY_HIGH,
|
|
||||||
'due_at' => now()->subHour(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(app(FindingNotificationService::class)->dispatch($mediumFinding, AlertRule::EVENT_FINDINGS_OVERDUE)['external_delivery_count'])->toBe(0)
|
|
||||||
->and(app(FindingNotificationService::class)->dispatch($scopedOutFinding, AlertRule::EVENT_FINDINGS_OVERDUE)['external_delivery_count'])->toBe(0);
|
|
||||||
|
|
||||||
app(FindingNotificationService::class)->dispatch($trackedFinding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
app(FindingNotificationService::class)->dispatch($trackedFinding->fresh(), AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
|
|
||||||
$deliveries = AlertDelivery::query()
|
|
||||||
->where('workspace_id', $workspaceId)
|
|
||||||
->where('event_type', AlertRule::EVENT_FINDINGS_OVERDUE)
|
|
||||||
->orderBy('id')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
expect($deliveries)->toHaveCount(2)
|
|
||||||
->and($deliveries[0]->status)->toBe(AlertDelivery::STATUS_QUEUED)
|
|
||||||
->and($deliveries[1]->status)->toBe(AlertDelivery::STATUS_SUPPRESSED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('inherits quiet hours deferral for finding alert copies', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$workspaceId = (int) $tenant->workspace_id;
|
|
||||||
|
|
||||||
$destination = AlertDestination::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'is_enabled' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$rule = AlertRule::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'event_type' => AlertRule::EVENT_FINDINGS_ASSIGNED,
|
|
||||||
'minimum_severity' => Finding::SEVERITY_LOW,
|
|
||||||
'is_enabled' => true,
|
|
||||||
'cooldown_seconds' => 0,
|
|
||||||
'quiet_hours_enabled' => true,
|
|
||||||
'quiet_hours_start' => '00:00',
|
|
||||||
'quiet_hours_end' => '23:59',
|
|
||||||
'quiet_hours_timezone' => 'UTC',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$rule->destinations()->attach($destination->getKey(), [
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $owner->getKey(),
|
|
||||||
'severity' => Finding::SEVERITY_LOW,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
$delivery = AlertDelivery::query()->latest('id')->first();
|
|
||||||
|
|
||||||
expect($result['external_delivery_count'])->toBe(1)
|
|
||||||
->and($delivery)->not->toBeNull()
|
|
||||||
->and($delivery?->status)->toBe(AlertDelivery::STATUS_DEFERRED);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders finding event labels and filters in the existing alert deliveries viewer', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create();
|
|
||||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
|
||||||
$workspaceId = (int) $tenant->workspace_id;
|
|
||||||
|
|
||||||
$rule = AlertRule::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$destination = AlertDestination::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'is_enabled' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$delivery = AlertDelivery::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'alert_rule_id' => (int) $rule->getKey(),
|
|
||||||
'alert_destination_id' => (int) $destination->getKey(),
|
|
||||||
'event_type' => AlertRule::EVENT_FINDINGS_OVERDUE,
|
|
||||||
'payload' => [
|
|
||||||
'title' => 'Finding overdue',
|
|
||||||
'body' => 'A finding is overdue and needs follow-up.',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
Livewire::test(ListAlertDeliveries::class)
|
|
||||||
->filterTable('event_type', AlertRule::EVENT_FINDINGS_OVERDUE)
|
|
||||||
->assertCanSeeTableRecords([$delivery])
|
|
||||||
->assertSee('Finding overdue');
|
|
||||||
|
|
||||||
expect(AlertRuleResource::eventTypeLabel(AlertRule::EVENT_FINDINGS_OVERDUE))->toBe('Finding overdue');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves alerts read and mutation boundaries for the existing admin surfaces', function (): void {
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
$viewer = User::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $viewer->getKey(),
|
|
||||||
'role' => 'readonly',
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $workspace->getKey());
|
|
||||||
|
|
||||||
$this->actingAs($viewer)
|
|
||||||
->get(AlertRuleResource::getUrl(panel: 'admin'))
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$this->actingAs($viewer)
|
|
||||||
->get(AlertDeliveryResource::getUrl(panel: 'admin'))
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$this->actingAs($viewer)
|
|
||||||
->get(AlertRuleResource::getUrl('create', panel: 'admin'))
|
|
||||||
->assertForbidden();
|
|
||||||
|
|
||||||
$resolver = \Mockery::mock(WorkspaceCapabilityResolver::class);
|
|
||||||
$resolver->shouldReceive('isMember')->andReturnTrue();
|
|
||||||
$resolver->shouldReceive('can')->andReturnFalse();
|
|
||||||
app()->instance(WorkspaceCapabilityResolver::class, $resolver);
|
|
||||||
|
|
||||||
$this->actingAs($viewer)
|
|
||||||
->get(AlertRuleResource::getUrl(panel: 'admin'))
|
|
||||||
->assertForbidden();
|
|
||||||
|
|
||||||
$this->actingAs($viewer)
|
|
||||||
->get(AlertDeliveryResource::getUrl(panel: 'admin'))
|
|
||||||
->assertForbidden();
|
|
||||||
|
|
||||||
$outsider = User::factory()->create();
|
|
||||||
app()->forgetInstance(WorkspaceCapabilityResolver::class);
|
|
||||||
|
|
||||||
$this->actingAs($outsider)
|
|
||||||
->get(AlertRuleResource::getUrl(panel: 'admin'))
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
@ -194,41 +194,3 @@ function invokeSlaDueEvents(int $workspaceId, CarbonImmutable $windowStart): arr
|
|||||||
->and($first[0]['fingerprint_key'])->toBe($second[0]['fingerprint_key'])
|
->and($first[0]['fingerprint_key'])->toBe($second[0]['fingerprint_key'])
|
||||||
->and($first[0]['fingerprint_key'])->not->toBe($third[0]['fingerprint_key']);
|
->and($first[0]['fingerprint_key'])->not->toBe($third[0]['fingerprint_key']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps aggregate sla due alerts separate from finding-level due soon reminders', function (): void {
|
|
||||||
$now = CarbonImmutable::parse('2026-04-22T12:00:00Z');
|
|
||||||
CarbonImmutable::setTestNow($now);
|
|
||||||
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$workspaceId = (int) session()->get(WorkspaceContext::SESSION_KEY);
|
|
||||||
|
|
||||||
Finding::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'severity' => Finding::SEVERITY_HIGH,
|
|
||||||
'due_at' => $now->subHour(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Finding::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'tenant_id' => $tenant->getKey(),
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'severity' => Finding::SEVERITY_CRITICAL,
|
|
||||||
'due_at' => $now->addHours(6),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$events = invokeSlaDueEvents($workspaceId, $now->subDay());
|
|
||||||
|
|
||||||
expect($events)->toHaveCount(1)
|
|
||||||
->and($events[0]['event_type'])->toBe(AlertRule::EVENT_SLA_DUE)
|
|
||||||
->and($events[0]['metadata'])->toMatchArray([
|
|
||||||
'overdue_total' => 1,
|
|
||||||
'overdue_by_severity' => [
|
|
||||||
'critical' => 0,
|
|
||||||
'high' => 1,
|
|
||||||
'medium' => 0,
|
|
||||||
'low' => 0,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|||||||
@ -1,138 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
it('redirects intake visits without workspace context into the existing workspace chooser flow', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$workspaceA = Workspace::factory()->create();
|
|
||||||
$workspaceB = Workspace::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspaceA->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspaceB->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->get(FindingsIntakeQueue::getUrl(panel: 'admin'))
|
|
||||||
->assertRedirect('/admin/choose-workspace');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 404 for users outside the active workspace on the intake route', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) Workspace::factory()->create()->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->get(FindingsIntakeQueue::getUrl(panel: 'admin'))
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 403 for workspace members with no currently viewable findings scope anywhere', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Tenant::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'status' => 'active',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->get(FindingsIntakeQueue::getUrl(panel: 'admin'))
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('suppresses hidden-tenant findings and keeps their detail route not found', function (): void {
|
|
||||||
$visibleTenant = Tenant::factory()->create(['status' => 'active']);
|
|
||||||
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
|
|
||||||
$hiddenTenant = Tenant::factory()->create([
|
|
||||||
'status' => 'active',
|
|
||||||
'workspace_id' => (int) $visibleTenant->workspace_id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$visibleFinding = Finding::factory()->for($visibleTenant)->create([
|
|
||||||
'workspace_id' => (int) $visibleTenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'assignee_user_id' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$hiddenFinding = Finding::factory()->for($hiddenTenant)->create([
|
|
||||||
'workspace_id' => (int) $hiddenTenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
'assignee_user_id' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
setAdminPanelContext();
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $visibleTenant->workspace_id);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(FindingsIntakeQueue::class)
|
|
||||||
->assertCanSeeTableRecords([$visibleFinding])
|
|
||||||
->assertCanNotSeeTableRecords([$hiddenFinding]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $visibleTenant->workspace_id])
|
|
||||||
->get(FindingResource::getUrl('view', ['record' => $hiddenFinding], panel: 'tenant', tenant: $hiddenTenant))
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps inspect access while disabling claim for members without assign capability', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
|
||||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
'assignee_user_id' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
setAdminPanelContext();
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(FindingsIntakeQueue::class)
|
|
||||||
->assertCanSeeTableRecords([$finding])
|
|
||||||
->assertTableActionVisible('claim', $finding)
|
|
||||||
->assertTableActionDisabled('claim', $finding)
|
|
||||||
->callTableAction('claim', $finding);
|
|
||||||
|
|
||||||
expect($finding->refresh()->assignee_user_id)->toBeNull();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
|
||||||
->get(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
|
|
||||||
->assertOk();
|
|
||||||
});
|
|
||||||
@ -1,232 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantMembership;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Findings\FindingAssignmentHygieneService;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
function assignmentHygieneServiceContext(string $role = 'readonly', string $workspaceRole = 'readonly'): array
|
|
||||||
{
|
|
||||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
|
||||||
[$user, $tenant] = createUserWithTenant($tenant, role: $role, workspaceRole: $workspaceRole);
|
|
||||||
|
|
||||||
return [
|
|
||||||
app(FindingAssignmentHygieneService::class),
|
|
||||||
$user,
|
|
||||||
$tenant->workspace()->firstOrFail(),
|
|
||||||
$tenant,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function assignmentHygieneFinding(Tenant $tenant, array $attributes = []): Finding
|
|
||||||
{
|
|
||||||
return Finding::factory()->for($tenant)->create(array_merge([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'subject_external_id' => fake()->uuid(),
|
|
||||||
], $attributes));
|
|
||||||
}
|
|
||||||
|
|
||||||
function recordAssignmentHygieneWorkflowAudit(Finding $finding, string $action, CarbonImmutable $recordedAt): AuditLog
|
|
||||||
{
|
|
||||||
return AuditLog::query()->create([
|
|
||||||
'workspace_id' => (int) $finding->workspace_id,
|
|
||||||
'tenant_id' => (int) $finding->tenant_id,
|
|
||||||
'action' => $action,
|
|
||||||
'status' => 'success',
|
|
||||||
'resource_type' => 'finding',
|
|
||||||
'resource_id' => (string) $finding->getKey(),
|
|
||||||
'summary' => 'Test workflow activity',
|
|
||||||
'recorded_at' => $recordedAt,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('classifies broken assignments from current tenant entitlement loss and soft-deleted assignees', function (): void {
|
|
||||||
[$service, $viewer, $workspace, $tenant] = assignmentHygieneServiceContext();
|
|
||||||
|
|
||||||
$lostMember = User::factory()->create(['name' => 'Lost Member']);
|
|
||||||
createUserWithTenant($tenant, $lostMember, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
TenantMembership::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('user_id', (int) $lostMember->getKey())
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
$softDeletedAssignee = User::factory()->create(['name' => 'Deleted Member']);
|
|
||||||
createUserWithTenant($tenant, $softDeletedAssignee, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
$softDeletedAssignee->delete();
|
|
||||||
|
|
||||||
$healthyAssignee = User::factory()->create(['name' => 'Healthy Assignee']);
|
|
||||||
createUserWithTenant($tenant, $healthyAssignee, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
|
|
||||||
$brokenByMembership = assignmentHygieneFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $viewer->getKey(),
|
|
||||||
'assignee_user_id' => (int) $lostMember->getKey(),
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'subject_external_id' => 'broken-membership',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$brokenBySoftDelete = assignmentHygieneFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $viewer->getKey(),
|
|
||||||
'assignee_user_id' => (int) $softDeletedAssignee->getKey(),
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
'subject_external_id' => 'broken-soft-delete',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$healthyAssigned = assignmentHygieneFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $viewer->getKey(),
|
|
||||||
'assignee_user_id' => (int) $healthyAssignee->getKey(),
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
'subject_external_id' => 'healthy-assigned',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$ordinaryIntake = assignmentHygieneFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $viewer->getKey(),
|
|
||||||
'assignee_user_id' => null,
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
'subject_external_id' => 'ordinary-intake',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$issues = $service->issueQuery($workspace, $viewer)->get()->keyBy('id');
|
|
||||||
$summary = $service->summary($workspace, $viewer);
|
|
||||||
|
|
||||||
expect($issues->keys()->all())
|
|
||||||
->toContain((int) $brokenByMembership->getKey(), (int) $brokenBySoftDelete->getKey())
|
|
||||||
->not->toContain((int) $healthyAssigned->getKey(), (int) $ordinaryIntake->getKey())
|
|
||||||
->and($service->reasonLabelsFor($issues[$brokenByMembership->getKey()]))
|
|
||||||
->toBe(['Broken assignment'])
|
|
||||||
->and($service->reasonLabelsFor($issues[$brokenBySoftDelete->getKey()]))
|
|
||||||
->toBe(['Broken assignment'])
|
|
||||||
->and($issues[$brokenBySoftDelete->getKey()]->assigneeUser?->name)
|
|
||||||
->toBe('Deleted Member')
|
|
||||||
->and($summary)
|
|
||||||
->toBe([
|
|
||||||
'unique_issue_count' => 2,
|
|
||||||
'broken_assignment_count' => 2,
|
|
||||||
'stale_in_progress_count' => 0,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('classifies stale in-progress work from meaningful workflow activity and excludes recently advanced or merely overdue work', function (): void {
|
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 22, 9, 0, 0, 'UTC'));
|
|
||||||
|
|
||||||
[$service, $viewer, $workspace, $tenant] = assignmentHygieneServiceContext();
|
|
||||||
|
|
||||||
$staleFinding = assignmentHygieneFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $viewer->getKey(),
|
|
||||||
'assignee_user_id' => (int) $viewer->getKey(),
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'in_progress_at' => now()->subDays(10),
|
|
||||||
'subject_external_id' => 'stale-finding',
|
|
||||||
]);
|
|
||||||
recordAssignmentHygieneWorkflowAudit($staleFinding, AuditActionId::FindingInProgress->value, CarbonImmutable::now()->subDays(10));
|
|
||||||
|
|
||||||
$recentlyAssigned = assignmentHygieneFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $viewer->getKey(),
|
|
||||||
'assignee_user_id' => (int) $viewer->getKey(),
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'in_progress_at' => now()->subDays(10),
|
|
||||||
'subject_external_id' => 'recently-assigned',
|
|
||||||
]);
|
|
||||||
recordAssignmentHygieneWorkflowAudit($recentlyAssigned, AuditActionId::FindingAssigned->value, CarbonImmutable::now()->subDays(2));
|
|
||||||
|
|
||||||
$recentlyReopened = assignmentHygieneFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $viewer->getKey(),
|
|
||||||
'assignee_user_id' => (int) $viewer->getKey(),
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'in_progress_at' => now()->subDays(10),
|
|
||||||
'reopened_at' => now()->subDay(),
|
|
||||||
'subject_external_id' => 'recently-reopened',
|
|
||||||
]);
|
|
||||||
recordAssignmentHygieneWorkflowAudit($recentlyReopened, AuditActionId::FindingReopened->value, CarbonImmutable::now()->subDay());
|
|
||||||
|
|
||||||
$overdueButActive = assignmentHygieneFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $viewer->getKey(),
|
|
||||||
'assignee_user_id' => (int) $viewer->getKey(),
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'in_progress_at' => now()->subDays(10),
|
|
||||||
'due_at' => now()->subDay(),
|
|
||||||
'subject_external_id' => 'overdue-but-active',
|
|
||||||
]);
|
|
||||||
recordAssignmentHygieneWorkflowAudit($overdueButActive, AuditActionId::FindingAssigned->value, CarbonImmutable::now()->subHours(12));
|
|
||||||
|
|
||||||
$issues = $service->issueQuery(
|
|
||||||
$workspace,
|
|
||||||
$viewer,
|
|
||||||
reasonFilter: FindingAssignmentHygieneService::REASON_STALE_IN_PROGRESS,
|
|
||||||
)->get();
|
|
||||||
|
|
||||||
$summary = $service->summary($workspace, $viewer);
|
|
||||||
|
|
||||||
expect($issues->pluck('id')->all())
|
|
||||||
->toBe([(int) $staleFinding->getKey()])
|
|
||||||
->and($service->reasonLabelsFor($issues->firstOrFail()))
|
|
||||||
->toBe(['Stale in progress'])
|
|
||||||
->and($service->lastWorkflowActivityAt($issues->firstOrFail())?->toIso8601String())
|
|
||||||
->toBe(CarbonImmutable::now()->subDays(10)->toIso8601String())
|
|
||||||
->and($summary)
|
|
||||||
->toBe([
|
|
||||||
'unique_issue_count' => 1,
|
|
||||||
'broken_assignment_count' => 0,
|
|
||||||
'stale_in_progress_count' => 1,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('counts multi-reason findings once while excluding healthy assigned work and ordinary intake backlog', function (): void {
|
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 22, 9, 0, 0, 'UTC'));
|
|
||||||
|
|
||||||
[$service, $viewer, $workspace, $tenant] = assignmentHygieneServiceContext();
|
|
||||||
|
|
||||||
$lostMember = User::factory()->create(['name' => 'Lost Worker']);
|
|
||||||
createUserWithTenant($tenant, $lostMember, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
TenantMembership::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('user_id', (int) $lostMember->getKey())
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
$brokenAndStale = assignmentHygieneFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $viewer->getKey(),
|
|
||||||
'assignee_user_id' => (int) $lostMember->getKey(),
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'in_progress_at' => now()->subDays(8),
|
|
||||||
'subject_external_id' => 'broken-and-stale',
|
|
||||||
]);
|
|
||||||
recordAssignmentHygieneWorkflowAudit($brokenAndStale, AuditActionId::FindingInProgress->value, CarbonImmutable::now()->subDays(8));
|
|
||||||
|
|
||||||
assignmentHygieneFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $viewer->getKey(),
|
|
||||||
'assignee_user_id' => (int) $viewer->getKey(),
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'subject_external_id' => 'healthy-assigned',
|
|
||||||
]);
|
|
||||||
|
|
||||||
assignmentHygieneFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $viewer->getKey(),
|
|
||||||
'assignee_user_id' => null,
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
'subject_external_id' => 'ordinary-intake',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$issues = $service->issueQuery($workspace, $viewer)->get();
|
|
||||||
$summary = $service->summary($workspace, $viewer);
|
|
||||||
|
|
||||||
expect($issues)->toHaveCount(1)
|
|
||||||
->and((int) $issues->firstOrFail()->getKey())->toBe((int) $brokenAndStale->getKey())
|
|
||||||
->and($service->reasonLabelsFor($issues->firstOrFail()))
|
|
||||||
->toBe(['Broken assignment', 'Stale in progress'])
|
|
||||||
->and($summary)
|
|
||||||
->toBe([
|
|
||||||
'unique_issue_count' => 1,
|
|
||||||
'broken_assignment_count' => 1,
|
|
||||||
'stale_in_progress_count' => 1,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@ -1,176 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Findings\FindingsHygieneReport;
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantMembership;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use App\Support\Workspaces\WorkspaceOverviewBuilder;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
|
|
||||||
use function Pest\Laravel\mock;
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
function findingsHygieneOverviewContext(string $role = 'readonly', string $workspaceRole = 'readonly'): array
|
|
||||||
{
|
|
||||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
|
||||||
[$user, $tenant] = createUserWithTenant($tenant, role: $role, workspaceRole: $workspaceRole);
|
|
||||||
|
|
||||||
return [$user, $tenant, $tenant->workspace()->firstOrFail()];
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeFindingsHygieneOverviewFinding(Tenant $tenant, array $attributes = []): Finding
|
|
||||||
{
|
|
||||||
$subjectDisplayName = $attributes['subject_display_name'] ?? null;
|
|
||||||
unset($attributes['subject_display_name']);
|
|
||||||
|
|
||||||
if (is_string($subjectDisplayName) && $subjectDisplayName !== '') {
|
|
||||||
$attributes['evidence_jsonb'] = array_merge(
|
|
||||||
is_array($attributes['evidence_jsonb'] ?? null) ? $attributes['evidence_jsonb'] : [],
|
|
||||||
['display_name' => $subjectDisplayName],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Finding::factory()->for($tenant)->create(array_merge([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'subject_external_id' => fake()->uuid(),
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
], $attributes));
|
|
||||||
}
|
|
||||||
|
|
||||||
function recordFindingsHygieneOverviewAudit(Finding $finding, string $action, CarbonImmutable $recordedAt): AuditLog
|
|
||||||
{
|
|
||||||
return AuditLog::query()->create([
|
|
||||||
'workspace_id' => (int) $finding->workspace_id,
|
|
||||||
'tenant_id' => (int) $finding->tenant_id,
|
|
||||||
'action' => $action,
|
|
||||||
'status' => 'success',
|
|
||||||
'resource_type' => 'finding',
|
|
||||||
'resource_id' => (string) $finding->getKey(),
|
|
||||||
'summary' => 'Test workflow activity',
|
|
||||||
'recorded_at' => $recordedAt,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('adds a findings hygiene signal to the workspace overview and renders the report CTA', function (): void {
|
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 22, 9, 0, 0, 'UTC'));
|
|
||||||
|
|
||||||
[$user, $tenant, $workspace] = findingsHygieneOverviewContext();
|
|
||||||
|
|
||||||
$lostMember = User::factory()->create(['name' => 'Lost Member']);
|
|
||||||
createUserWithTenant($tenant, $lostMember, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
TenantMembership::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('user_id', (int) $lostMember->getKey())
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
makeFindingsHygieneOverviewFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $user->getKey(),
|
|
||||||
'assignee_user_id' => (int) $lostMember->getKey(),
|
|
||||||
'subject_display_name' => 'Broken Assignment',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$staleInProgress = makeFindingsHygieneOverviewFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $user->getKey(),
|
|
||||||
'assignee_user_id' => (int) $user->getKey(),
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'in_progress_at' => now()->subDays(8),
|
|
||||||
'subject_display_name' => 'Stale In Progress',
|
|
||||||
]);
|
|
||||||
recordFindingsHygieneOverviewAudit($staleInProgress, AuditActionId::FindingInProgress->value, CarbonImmutable::now()->subDays(8));
|
|
||||||
|
|
||||||
$signal = app(WorkspaceOverviewBuilder::class)->build($workspace, $user)['findings_hygiene_signal'];
|
|
||||||
|
|
||||||
expect($signal)
|
|
||||||
->toBe([
|
|
||||||
'headline' => '2 visible hygiene issues need follow-up',
|
|
||||||
'description' => '1 broken assignment and 1 stale in-progress finding need repair.',
|
|
||||||
'unique_issue_count' => 2,
|
|
||||||
'broken_assignment_count' => 1,
|
|
||||||
'stale_in_progress_count' => 1,
|
|
||||||
'is_calm' => false,
|
|
||||||
'cta_label' => 'Open hygiene report',
|
|
||||||
'cta_url' => FindingsHygieneReport::getUrl(panel: 'admin'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->get('/admin')
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Findings hygiene')
|
|
||||||
->assertSee('Unique issues: 2')
|
|
||||||
->assertSee('Broken assignments: 1')
|
|
||||||
->assertSee('Stale in progress: 1')
|
|
||||||
->assertSee('Open hygiene report');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the overview signal calm and suppresses hidden-tenant hygiene issues from counts and copy', function (): void {
|
|
||||||
[$user, $visibleTenant, $workspace] = findingsHygieneOverviewContext();
|
|
||||||
|
|
||||||
$hiddenTenant = Tenant::factory()->create([
|
|
||||||
'status' => 'active',
|
|
||||||
'workspace_id' => (int) $visibleTenant->workspace_id,
|
|
||||||
'name' => 'Hidden Tenant',
|
|
||||||
]);
|
|
||||||
createUserWithTenant($hiddenTenant, $user, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
|
|
||||||
$lostMember = User::factory()->create(['name' => 'Hidden Lost Member']);
|
|
||||||
createUserWithTenant($hiddenTenant, $lostMember, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
TenantMembership::query()
|
|
||||||
->where('tenant_id', (int) $hiddenTenant->getKey())
|
|
||||||
->where('user_id', (int) $lostMember->getKey())
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
makeFindingsHygieneOverviewFinding($hiddenTenant, [
|
|
||||||
'owner_user_id' => (int) $user->getKey(),
|
|
||||||
'assignee_user_id' => (int) $lostMember->getKey(),
|
|
||||||
'subject_display_name' => 'Hidden Hygiene Issue',
|
|
||||||
]);
|
|
||||||
|
|
||||||
mock(CapabilityResolver::class, function ($mock) use ($visibleTenant, $hiddenTenant): void {
|
|
||||||
$mock->shouldReceive('primeMemberships')->atLeast()->once();
|
|
||||||
$mock->shouldReceive('isMember')
|
|
||||||
->andReturnUsing(static function (User $user, Tenant $tenant) use ($visibleTenant, $hiddenTenant): bool {
|
|
||||||
expect([(int) $visibleTenant->getKey(), (int) $hiddenTenant->getKey()])
|
|
||||||
->toContain((int) $tenant->getKey());
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
$mock->shouldReceive('can')
|
|
||||||
->andReturnUsing(static function (User $user, Tenant $tenant, string $capability) use ($visibleTenant, $hiddenTenant): bool {
|
|
||||||
expect([(int) $visibleTenant->getKey(), (int) $hiddenTenant->getKey()])
|
|
||||||
->toContain((int) $tenant->getKey());
|
|
||||||
|
|
||||||
return $capability === Capabilities::TENANT_FINDINGS_VIEW
|
|
||||||
&& (int) $tenant->getKey() === (int) $visibleTenant->getKey();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$signal = app(WorkspaceOverviewBuilder::class)->build($workspace, $user)['findings_hygiene_signal'];
|
|
||||||
|
|
||||||
expect($signal['unique_issue_count'])->toBe(0)
|
|
||||||
->and($signal['broken_assignment_count'])->toBe(0)
|
|
||||||
->and($signal['stale_in_progress_count'])->toBe(0)
|
|
||||||
->and($signal['is_calm'])->toBeTrue()
|
|
||||||
->and($signal['headline'])->toBe('Findings hygiene is calm')
|
|
||||||
->and($signal['description'])->toContain('No broken assignments or stale in-progress work are visible');
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->get('/admin')
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Findings hygiene is calm')
|
|
||||||
->assertSee('Unique issues: 0')
|
|
||||||
->assertSee('Calm')
|
|
||||||
->assertDontSee('Hidden Hygiene Issue');
|
|
||||||
});
|
|
||||||
@ -1,399 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Findings\FindingsHygieneReport;
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\TenantMembership;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\Workspace;
|
|
||||||
use App\Models\WorkspaceMembership;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
use function Pest\Laravel\mock;
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
function findingsHygieneActingUser(string $role = 'readonly', string $workspaceRole = 'readonly'): array
|
|
||||||
{
|
|
||||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
|
||||||
[$user, $tenant] = createUserWithTenant($tenant, role: $role, workspaceRole: $workspaceRole);
|
|
||||||
|
|
||||||
test()->actingAs($user);
|
|
||||||
setAdminPanelContext();
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
|
|
||||||
return [$user, $tenant];
|
|
||||||
}
|
|
||||||
|
|
||||||
function findingsHygienePage(?User $user = null, array $query = [])
|
|
||||||
{
|
|
||||||
if ($user instanceof User) {
|
|
||||||
test()->actingAs($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
setAdminPanelContext();
|
|
||||||
|
|
||||||
$factory = $query === []
|
|
||||||
? Livewire::actingAs(auth()->user())
|
|
||||||
: Livewire::withQueryParams($query)->actingAs(auth()->user());
|
|
||||||
|
|
||||||
return $factory->test(FindingsHygieneReport::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeFindingsHygieneFinding(Tenant $tenant, array $attributes = []): Finding
|
|
||||||
{
|
|
||||||
$subjectDisplayName = $attributes['subject_display_name'] ?? null;
|
|
||||||
unset($attributes['subject_display_name']);
|
|
||||||
|
|
||||||
if (is_string($subjectDisplayName) && $subjectDisplayName !== '') {
|
|
||||||
$attributes['evidence_jsonb'] = array_merge(
|
|
||||||
is_array($attributes['evidence_jsonb'] ?? null) ? $attributes['evidence_jsonb'] : [],
|
|
||||||
['display_name' => $subjectDisplayName],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Finding::factory()->for($tenant)->create(array_merge([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'subject_external_id' => fake()->uuid(),
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
], $attributes));
|
|
||||||
}
|
|
||||||
|
|
||||||
function recordFindingsHygieneAudit(Finding $finding, string $action, CarbonImmutable $recordedAt): AuditLog
|
|
||||||
{
|
|
||||||
return AuditLog::query()->create([
|
|
||||||
'workspace_id' => (int) $finding->workspace_id,
|
|
||||||
'tenant_id' => (int) $finding->tenant_id,
|
|
||||||
'action' => $action,
|
|
||||||
'status' => 'success',
|
|
||||||
'resource_type' => 'finding',
|
|
||||||
'resource_id' => (string) $finding->getKey(),
|
|
||||||
'summary' => 'Test workflow activity',
|
|
||||||
'recorded_at' => $recordedAt,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('redirects hygiene report visits without workspace context into the existing workspace chooser flow', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$workspaceA = Workspace::factory()->create();
|
|
||||||
$workspaceB = Workspace::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspaceA->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspaceB->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->get(FindingsHygieneReport::getUrl(panel: 'admin'))
|
|
||||||
->assertRedirect('/admin/choose-workspace');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 404 for users outside the active workspace on the hygiene report route', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$workspace = Workspace::factory()->create();
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) Workspace::factory()->create()->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'owner',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->get(FindingsHygieneReport::getUrl(panel: 'admin'))
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the hygiene report accessible and calm for workspace members with zero visible hygiene scope', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$workspace = Workspace::factory()->create(['name' => 'Calm Workspace']);
|
|
||||||
|
|
||||||
WorkspaceMembership::factory()->create([
|
|
||||||
'workspace_id' => (int) $workspace->getKey(),
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'role' => 'readonly',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $workspace->getKey()])
|
|
||||||
->get(FindingsHygieneReport::getUrl(panel: 'admin'))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Findings hygiene report')
|
|
||||||
->assertSee('No visible hygiene issues right now');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows visible hygiene findings with reason labels, last activity, and row drilldown into tenant finding detail', function (): void {
|
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 22, 9, 0, 0, 'UTC'));
|
|
||||||
|
|
||||||
[$user, $tenant] = findingsHygieneActingUser();
|
|
||||||
|
|
||||||
$lostMember = User::factory()->create(['name' => 'Lost Member']);
|
|
||||||
createUserWithTenant($tenant, $lostMember, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
TenantMembership::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('user_id', (int) $lostMember->getKey())
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
$brokenAssignment = makeFindingsHygieneFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $user->getKey(),
|
|
||||||
'assignee_user_id' => (int) $lostMember->getKey(),
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'subject_display_name' => 'Broken Assignment Finding',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$staleInProgress = makeFindingsHygieneFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $user->getKey(),
|
|
||||||
'assignee_user_id' => (int) $user->getKey(),
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'in_progress_at' => now()->subDays(10),
|
|
||||||
'subject_display_name' => 'Stale Progress Finding',
|
|
||||||
]);
|
|
||||||
recordFindingsHygieneAudit($staleInProgress, AuditActionId::FindingInProgress->value, CarbonImmutable::now()->subDays(10));
|
|
||||||
|
|
||||||
findingsHygienePage($user)
|
|
||||||
->assertCanSeeTableRecords([$brokenAssignment, $staleInProgress])
|
|
||||||
->assertSee('Broken assignment')
|
|
||||||
->assertSee('Stale in progress')
|
|
||||||
->assertSee('Lost Member')
|
|
||||||
->assertSee('No current tenant membership');
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
|
||||||
->get(FindingsHygieneReport::getUrl(panel: 'admin'))
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Broken Assignment Finding')
|
|
||||||
->assertSee('/findings/'.$brokenAssignment->getKey(), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('suppresses hidden-tenant rows, counts, and tenant filter values inside an otherwise available report', function (): void {
|
|
||||||
$visibleTenant = Tenant::factory()->create(['status' => 'active', 'name' => 'Visible Tenant']);
|
|
||||||
[$user, $visibleTenant] = createUserWithTenant($visibleTenant, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
|
|
||||||
$hiddenTenant = Tenant::factory()->create([
|
|
||||||
'status' => 'active',
|
|
||||||
'workspace_id' => (int) $visibleTenant->workspace_id,
|
|
||||||
'name' => 'Hidden Tenant',
|
|
||||||
]);
|
|
||||||
createUserWithTenant($hiddenTenant, $user, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
|
|
||||||
$visibleAssignee = User::factory()->create(['name' => 'Visible Assignee']);
|
|
||||||
createUserWithTenant($visibleTenant, $visibleAssignee, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
TenantMembership::query()
|
|
||||||
->where('tenant_id', (int) $visibleTenant->getKey())
|
|
||||||
->where('user_id', (int) $visibleAssignee->getKey())
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
$hiddenAssignee = User::factory()->create(['name' => 'Hidden Assignee']);
|
|
||||||
createUserWithTenant($hiddenTenant, $hiddenAssignee, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
TenantMembership::query()
|
|
||||||
->where('tenant_id', (int) $hiddenTenant->getKey())
|
|
||||||
->where('user_id', (int) $hiddenAssignee->getKey())
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
$visibleFinding = makeFindingsHygieneFinding($visibleTenant, [
|
|
||||||
'owner_user_id' => (int) $user->getKey(),
|
|
||||||
'assignee_user_id' => (int) $visibleAssignee->getKey(),
|
|
||||||
'subject_display_name' => 'Visible Hygiene Finding',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$hiddenFinding = makeFindingsHygieneFinding($hiddenTenant, [
|
|
||||||
'owner_user_id' => (int) $user->getKey(),
|
|
||||||
'assignee_user_id' => (int) $hiddenAssignee->getKey(),
|
|
||||||
'subject_display_name' => 'Hidden Hygiene Finding',
|
|
||||||
]);
|
|
||||||
|
|
||||||
mock(CapabilityResolver::class, function ($mock) use ($visibleTenant, $hiddenTenant): void {
|
|
||||||
$mock->shouldReceive('primeMemberships')->atLeast()->once();
|
|
||||||
$mock->shouldReceive('can')
|
|
||||||
->andReturnUsing(static function (User $user, Tenant $tenant, string $capability) use ($visibleTenant, $hiddenTenant): bool {
|
|
||||||
expect([(int) $visibleTenant->getKey(), (int) $hiddenTenant->getKey()])
|
|
||||||
->toContain((int) $tenant->getKey());
|
|
||||||
|
|
||||||
return $capability === Capabilities::TENANT_FINDINGS_VIEW
|
|
||||||
&& (int) $tenant->getKey() === (int) $visibleTenant->getKey();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
setAdminPanelContext();
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $visibleTenant->workspace_id);
|
|
||||||
|
|
||||||
$component = findingsHygienePage($user)
|
|
||||||
->assertCanSeeTableRecords([$visibleFinding])
|
|
||||||
->assertCanNotSeeTableRecords([$hiddenFinding])
|
|
||||||
->assertDontSee('Hidden Tenant');
|
|
||||||
|
|
||||||
expect($component->instance()->summaryCounts())
|
|
||||||
->toBe([
|
|
||||||
'unique_issue_count' => 1,
|
|
||||||
'broken_assignment_count' => 1,
|
|
||||||
'stale_in_progress_count' => 0,
|
|
||||||
])
|
|
||||||
->and($component->instance()->availableFilters())
|
|
||||||
->toBe([
|
|
||||||
[
|
|
||||||
'key' => 'hygiene_scope',
|
|
||||||
'label' => 'Findings hygiene only',
|
|
||||||
'fixed' => true,
|
|
||||||
'options' => [],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => 'tenant',
|
|
||||||
'label' => 'Tenant',
|
|
||||||
'fixed' => false,
|
|
||||||
'options' => [
|
|
||||||
['value' => (string) $visibleTenant->getKey(), 'label' => $visibleTenant->name],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports fixed reason filters without duplicating a multi-reason finding in the all-issues view', function (): void {
|
|
||||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 4, 22, 9, 0, 0, 'UTC'));
|
|
||||||
|
|
||||||
[$user, $tenant] = findingsHygieneActingUser();
|
|
||||||
|
|
||||||
$lostMember = User::factory()->create(['name' => 'Lost Worker']);
|
|
||||||
createUserWithTenant($tenant, $lostMember, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
TenantMembership::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('user_id', (int) $lostMember->getKey())
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
$brokenOnly = makeFindingsHygieneFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $user->getKey(),
|
|
||||||
'assignee_user_id' => (int) $lostMember->getKey(),
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'subject_display_name' => 'Broken Only',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$staleOnly = makeFindingsHygieneFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $user->getKey(),
|
|
||||||
'assignee_user_id' => (int) $user->getKey(),
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'in_progress_at' => now()->subDays(9),
|
|
||||||
'subject_display_name' => 'Stale Only',
|
|
||||||
]);
|
|
||||||
recordFindingsHygieneAudit($staleOnly, AuditActionId::FindingInProgress->value, CarbonImmutable::now()->subDays(9));
|
|
||||||
|
|
||||||
$brokenAndStale = makeFindingsHygieneFinding($tenant, [
|
|
||||||
'owner_user_id' => (int) $user->getKey(),
|
|
||||||
'assignee_user_id' => (int) $lostMember->getKey(),
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'in_progress_at' => now()->subDays(10),
|
|
||||||
'subject_display_name' => 'Broken And Stale',
|
|
||||||
]);
|
|
||||||
recordFindingsHygieneAudit($brokenAndStale, AuditActionId::FindingInProgress->value, CarbonImmutable::now()->subDays(10));
|
|
||||||
|
|
||||||
$allIssues = findingsHygienePage($user);
|
|
||||||
$brokenAssignments = findingsHygienePage($user, ['reason' => 'broken_assignment']);
|
|
||||||
$staleInProgress = findingsHygienePage($user, ['reason' => 'stale_in_progress']);
|
|
||||||
|
|
||||||
$allIssues
|
|
||||||
->assertCanSeeTableRecords([$brokenOnly, $staleOnly, $brokenAndStale])
|
|
||||||
->assertSee('Broken And Stale');
|
|
||||||
|
|
||||||
$brokenAssignments
|
|
||||||
->assertCanSeeTableRecords([$brokenOnly, $brokenAndStale])
|
|
||||||
->assertCanNotSeeTableRecords([$staleOnly]);
|
|
||||||
|
|
||||||
$staleInProgress
|
|
||||||
->assertCanSeeTableRecords([$staleOnly, $brokenAndStale])
|
|
||||||
->assertCanNotSeeTableRecords([$brokenOnly]);
|
|
||||||
|
|
||||||
expect($allIssues->instance()->summaryCounts())
|
|
||||||
->toBe([
|
|
||||||
'unique_issue_count' => 3,
|
|
||||||
'broken_assignment_count' => 2,
|
|
||||||
'stale_in_progress_count' => 2,
|
|
||||||
])
|
|
||||||
->and($allIssues->instance()->availableReasonFilters())
|
|
||||||
->toBe([
|
|
||||||
[
|
|
||||||
'key' => 'all',
|
|
||||||
'label' => 'All issues',
|
|
||||||
'active' => true,
|
|
||||||
'badge_count' => 3,
|
|
||||||
'url' => FindingsHygieneReport::getUrl(panel: 'admin'),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => 'broken_assignment',
|
|
||||||
'label' => 'Broken assignment',
|
|
||||||
'active' => false,
|
|
||||||
'badge_count' => 2,
|
|
||||||
'url' => FindingsHygieneReport::getUrl(panel: 'admin', parameters: ['reason' => 'broken_assignment']),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'key' => 'stale_in_progress',
|
|
||||||
'label' => 'Stale in progress',
|
|
||||||
'active' => false,
|
|
||||||
'badge_count' => 2,
|
|
||||||
'url' => FindingsHygieneReport::getUrl(panel: 'admin', parameters: ['reason' => 'stale_in_progress']),
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('explains when the active tenant prefilter hides otherwise visible hygiene issues and clears it in place', function (): void {
|
|
||||||
[$user, $tenantA] = findingsHygieneActingUser();
|
|
||||||
|
|
||||||
$tenantB = Tenant::factory()->create([
|
|
||||||
'status' => 'active',
|
|
||||||
'workspace_id' => (int) $tenantA->workspace_id,
|
|
||||||
'name' => 'Beta Tenant',
|
|
||||||
]);
|
|
||||||
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
|
|
||||||
$lostMember = User::factory()->create(['name' => 'Lost Member']);
|
|
||||||
createUserWithTenant($tenantA, $lostMember, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
TenantMembership::query()
|
|
||||||
->where('tenant_id', (int) $tenantA->getKey())
|
|
||||||
->where('user_id', (int) $lostMember->getKey())
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
$tenantAIssue = makeFindingsHygieneFinding($tenantA, [
|
|
||||||
'owner_user_id' => (int) $user->getKey(),
|
|
||||||
'assignee_user_id' => (int) $lostMember->getKey(),
|
|
||||||
'subject_display_name' => 'Tenant A Issue',
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
|
||||||
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$component = findingsHygienePage($user)
|
|
||||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
|
|
||||||
->assertCanNotSeeTableRecords([$tenantAIssue])
|
|
||||||
->assertSee('No hygiene issues match this tenant scope')
|
|
||||||
->assertActionVisible('clear_tenant_filter');
|
|
||||||
|
|
||||||
$component->callAction('clear_tenant_filter')
|
|
||||||
->assertCanSeeTableRecords([$tenantAIssue]);
|
|
||||||
|
|
||||||
expect($component->instance()->appliedScope())
|
|
||||||
->toBe([
|
|
||||||
'workspace_scoped' => true,
|
|
||||||
'fixed_scope' => 'visible_findings_hygiene_only',
|
|
||||||
'reason_filter' => 'all',
|
|
||||||
'reason_filter_label' => 'All issues',
|
|
||||||
'tenant_prefilter_source' => 'none',
|
|
||||||
'tenant_label' => null,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@ -1,104 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
|
||||||
use App\Filament\Pages\Findings\MyFindingsInbox;
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Services\Findings\FindingWorkflowService;
|
|
||||||
use App\Support\Audit\AuditActionId;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
it('mounts the claim confirmation and moves the finding into my findings without changing owner or lifecycle', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
|
||||||
[$user, $tenant] = createUserWithTenant($tenant, role: 'manager', workspaceRole: 'manager');
|
|
||||||
|
|
||||||
$owner = User::factory()->create();
|
|
||||||
createUserWithTenant($tenant, $owner, role: 'owner', workspaceRole: 'manager');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->reopened()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => null,
|
|
||||||
'subject_external_id' => 'claimable',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($user);
|
|
||||||
setAdminPanelContext();
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(FindingsIntakeQueue::class)
|
|
||||||
->assertTableActionVisible('claim', $finding)
|
|
||||||
->mountTableAction('claim', $finding)
|
|
||||||
->callMountedTableAction()
|
|
||||||
->assertCanNotSeeTableRecords([$finding]);
|
|
||||||
|
|
||||||
$finding->refresh();
|
|
||||||
|
|
||||||
expect((int) $finding->assignee_user_id)->toBe((int) $user->getKey())
|
|
||||||
->and((int) $finding->owner_user_id)->toBe((int) $owner->getKey())
|
|
||||||
->and($finding->status)->toBe(Finding::STATUS_REOPENED);
|
|
||||||
|
|
||||||
$audit = AuditLog::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('resource_type', 'finding')
|
|
||||||
->where('resource_id', (string) $finding->getKey())
|
|
||||||
->where('action', AuditActionId::FindingAssigned->value)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
expect($audit)->not->toBeNull()
|
|
||||||
->and(data_get($audit?->metadata ?? [], 'assignee_user_id'))->toBe((int) $user->getKey())
|
|
||||||
->and(data_get($audit?->metadata ?? [], 'owner_user_id'))->toBe((int) $owner->getKey());
|
|
||||||
|
|
||||||
Livewire::actingAs($user)
|
|
||||||
->test(MyFindingsInbox::class)
|
|
||||||
->assertCanSeeTableRecords([$finding]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('refuses a stale claim after another operator already claimed the finding first', function (): void {
|
|
||||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
|
||||||
[$operatorA, $tenant] = createUserWithTenant($tenant, role: 'manager', workspaceRole: 'manager');
|
|
||||||
|
|
||||||
$operatorB = User::factory()->create();
|
|
||||||
createUserWithTenant($tenant, $operatorB, role: 'manager', workspaceRole: 'manager');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
'assignee_user_id' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->actingAs($operatorA);
|
|
||||||
setAdminPanelContext();
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
|
|
||||||
$component = Livewire::actingAs($operatorA)
|
|
||||||
->test(FindingsIntakeQueue::class)
|
|
||||||
->mountTableAction('claim', $finding);
|
|
||||||
|
|
||||||
app(FindingWorkflowService::class)->claim($finding, $tenant, $operatorB);
|
|
||||||
|
|
||||||
$component
|
|
||||||
->callMountedTableAction();
|
|
||||||
|
|
||||||
expect((int) $finding->refresh()->assignee_user_id)->toBe((int) $operatorB->getKey());
|
|
||||||
|
|
||||||
Livewire::actingAs($operatorA)
|
|
||||||
->test(FindingsIntakeQueue::class)
|
|
||||||
->assertCanNotSeeTableRecords([$finding]);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
AuditLog::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('resource_type', 'finding')
|
|
||||||
->where('resource_id', (string) $finding->getKey())
|
|
||||||
->where('action', AuditActionId::FindingAssigned->value)
|
|
||||||
->count()
|
|
||||||
)->toBe(1);
|
|
||||||
});
|
|
||||||
@ -1,347 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Pages\Findings\FindingsIntakeQueue;
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\Tenant;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Livewire\Livewire;
|
|
||||||
|
|
||||||
function findingsIntakeActingUser(string $role = 'owner', string $workspaceRole = 'readonly'): array
|
|
||||||
{
|
|
||||||
$tenant = Tenant::factory()->create(['status' => 'active']);
|
|
||||||
[$user, $tenant] = createUserWithTenant($tenant, role: $role, workspaceRole: $workspaceRole);
|
|
||||||
|
|
||||||
test()->actingAs($user);
|
|
||||||
setAdminPanelContext();
|
|
||||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
|
||||||
|
|
||||||
return [$user, $tenant];
|
|
||||||
}
|
|
||||||
|
|
||||||
function findingsIntakePage(?User $user = null, array $query = [])
|
|
||||||
{
|
|
||||||
if ($user instanceof User) {
|
|
||||||
test()->actingAs($user);
|
|
||||||
}
|
|
||||||
|
|
||||||
setAdminPanelContext();
|
|
||||||
|
|
||||||
$factory = $query === []
|
|
||||||
? Livewire::actingAs(auth()->user())
|
|
||||||
: Livewire::withQueryParams($query)->actingAs(auth()->user());
|
|
||||||
|
|
||||||
return $factory->test(FindingsIntakeQueue::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeIntakeFinding(Tenant $tenant, array $attributes = []): Finding
|
|
||||||
{
|
|
||||||
return Finding::factory()->for($tenant)->create(array_merge([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'assignee_user_id' => null,
|
|
||||||
'owner_user_id' => null,
|
|
||||||
'subject_external_id' => fake()->uuid(),
|
|
||||||
], $attributes));
|
|
||||||
}
|
|
||||||
|
|
||||||
it('shows only visible unassigned open findings and exposes fixed queue view counts', function (): void {
|
|
||||||
[$user, $tenantA] = findingsIntakeActingUser();
|
|
||||||
$tenantA->forceFill(['name' => 'Alpha Tenant'])->save();
|
|
||||||
|
|
||||||
$tenantB = Tenant::factory()->create([
|
|
||||||
'status' => 'active',
|
|
||||||
'workspace_id' => (int) $tenantA->workspace_id,
|
|
||||||
'name' => 'Tenant Bravo',
|
|
||||||
]);
|
|
||||||
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
|
|
||||||
$hiddenTenant = Tenant::factory()->create([
|
|
||||||
'status' => 'active',
|
|
||||||
'workspace_id' => (int) $tenantA->workspace_id,
|
|
||||||
'name' => 'Hidden Tenant',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$otherAssignee = User::factory()->create();
|
|
||||||
createUserWithTenant($tenantA, $otherAssignee, role: 'operator', workspaceRole: 'readonly');
|
|
||||||
|
|
||||||
$otherOwner = User::factory()->create();
|
|
||||||
createUserWithTenant($tenantB, $otherOwner, role: 'owner', workspaceRole: 'readonly');
|
|
||||||
|
|
||||||
$visibleNew = makeIntakeFinding($tenantA, [
|
|
||||||
'subject_external_id' => 'visible-new',
|
|
||||||
'severity' => Finding::SEVERITY_HIGH,
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$visibleReopened = makeIntakeFinding($tenantB, [
|
|
||||||
'subject_external_id' => 'visible-reopened',
|
|
||||||
'status' => Finding::STATUS_REOPENED,
|
|
||||||
'reopened_at' => now()->subHours(6),
|
|
||||||
'owner_user_id' => (int) $otherOwner->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$visibleTriaged = makeIntakeFinding($tenantA, [
|
|
||||||
'subject_external_id' => 'visible-triaged',
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$visibleInProgress = makeIntakeFinding($tenantB, [
|
|
||||||
'subject_external_id' => 'visible-progress',
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'due_at' => now()->subDay(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$assignedOpen = makeIntakeFinding($tenantA, [
|
|
||||||
'subject_external_id' => 'assigned-open',
|
|
||||||
'assignee_user_id' => (int) $otherAssignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$acknowledged = Finding::factory()->for($tenantA)->acknowledged()->create([
|
|
||||||
'workspace_id' => (int) $tenantA->workspace_id,
|
|
||||||
'assignee_user_id' => null,
|
|
||||||
'subject_external_id' => 'acknowledged',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$terminal = Finding::factory()->for($tenantA)->resolved()->create([
|
|
||||||
'workspace_id' => (int) $tenantA->workspace_id,
|
|
||||||
'assignee_user_id' => null,
|
|
||||||
'subject_external_id' => 'terminal',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$hidden = makeIntakeFinding($hiddenTenant, [
|
|
||||||
'subject_external_id' => 'hidden-intake',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$component = findingsIntakePage($user)
|
|
||||||
->assertCanSeeTableRecords([$visibleNew, $visibleReopened, $visibleTriaged, $visibleInProgress])
|
|
||||||
->assertCanNotSeeTableRecords([$assignedOpen, $acknowledged, $terminal, $hidden])
|
|
||||||
->assertSee('Owner: '.$otherOwner->name)
|
|
||||||
->assertSee('Needs triage')
|
|
||||||
->assertSee('Unassigned');
|
|
||||||
|
|
||||||
expect($component->instance()->summaryCounts())->toBe([
|
|
||||||
'visible_unassigned' => 4,
|
|
||||||
'visible_needs_triage' => 2,
|
|
||||||
'visible_overdue' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$queueViews = collect($component->instance()->queueViews())->keyBy('key');
|
|
||||||
|
|
||||||
expect($queueViews['unassigned']['badge_count'])->toBe(4)
|
|
||||||
->and($queueViews['unassigned']['active'])->toBeTrue()
|
|
||||||
->and($queueViews['needs_triage']['badge_count'])->toBe(2)
|
|
||||||
->and($queueViews['needs_triage']['active'])->toBeFalse();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults to the active tenant prefilter and lets the operator clear it without dropping intake scope', function (): void {
|
|
||||||
[$user, $tenantA] = findingsIntakeActingUser();
|
|
||||||
|
|
||||||
$tenantB = Tenant::factory()->create([
|
|
||||||
'status' => 'active',
|
|
||||||
'workspace_id' => (int) $tenantA->workspace_id,
|
|
||||||
'name' => 'Beta Tenant',
|
|
||||||
]);
|
|
||||||
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
|
|
||||||
$findingA = makeIntakeFinding($tenantA, [
|
|
||||||
'subject_external_id' => 'tenant-a',
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
]);
|
|
||||||
$findingB = makeIntakeFinding($tenantB, [
|
|
||||||
'subject_external_id' => 'tenant-b',
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
|
||||||
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$component = findingsIntakePage($user)
|
|
||||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
|
|
||||||
->assertCanSeeTableRecords([$findingB])
|
|
||||||
->assertCanNotSeeTableRecords([$findingA])
|
|
||||||
->assertActionVisible('clear_tenant_filter');
|
|
||||||
|
|
||||||
expect($component->instance()->appliedScope())->toBe([
|
|
||||||
'workspace_scoped' => true,
|
|
||||||
'fixed_scope' => 'visible_unassigned_open_findings_only',
|
|
||||||
'queue_view' => 'unassigned',
|
|
||||||
'queue_view_label' => 'Unassigned',
|
|
||||||
'tenant_prefilter_source' => 'active_tenant_context',
|
|
||||||
'tenant_label' => $tenantB->name,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$component->callAction('clear_tenant_filter')
|
|
||||||
->assertCanSeeTableRecords([$findingA, $findingB]);
|
|
||||||
|
|
||||||
expect($component->instance()->appliedScope())->toBe([
|
|
||||||
'workspace_scoped' => true,
|
|
||||||
'fixed_scope' => 'visible_unassigned_open_findings_only',
|
|
||||||
'queue_view' => 'unassigned',
|
|
||||||
'queue_view_label' => 'Unassigned',
|
|
||||||
'tenant_prefilter_source' => 'none',
|
|
||||||
'tenant_label' => null,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the needs triage view active when clearing the tenant prefilter', function (): void {
|
|
||||||
[$user, $tenantA] = findingsIntakeActingUser();
|
|
||||||
|
|
||||||
$tenantB = Tenant::factory()->create([
|
|
||||||
'status' => 'active',
|
|
||||||
'workspace_id' => (int) $tenantA->workspace_id,
|
|
||||||
'name' => 'Beta Tenant',
|
|
||||||
]);
|
|
||||||
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
|
|
||||||
$tenantATriage = makeIntakeFinding($tenantA, [
|
|
||||||
'subject_external_id' => 'tenant-a-triage',
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
]);
|
|
||||||
$tenantBTriage = makeIntakeFinding($tenantB, [
|
|
||||||
'subject_external_id' => 'tenant-b-triage',
|
|
||||||
'status' => Finding::STATUS_REOPENED,
|
|
||||||
'reopened_at' => now()->subHour(),
|
|
||||||
]);
|
|
||||||
$tenantBBacklog = makeIntakeFinding($tenantB, [
|
|
||||||
'subject_external_id' => 'tenant-b-backlog',
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
]);
|
|
||||||
|
|
||||||
session()->put(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY, [
|
|
||||||
(string) $tenantA->workspace_id => (int) $tenantB->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$component = findingsIntakePage($user, ['view' => 'needs_triage'])
|
|
||||||
->assertSet('tableFilters.tenant_id.value', (string) $tenantB->getKey())
|
|
||||||
->assertCanSeeTableRecords([$tenantBTriage])
|
|
||||||
->assertCanNotSeeTableRecords([$tenantATriage, $tenantBBacklog]);
|
|
||||||
|
|
||||||
expect($component->instance()->appliedScope())->toBe([
|
|
||||||
'workspace_scoped' => true,
|
|
||||||
'fixed_scope' => 'visible_unassigned_open_findings_only',
|
|
||||||
'queue_view' => 'needs_triage',
|
|
||||||
'queue_view_label' => 'Needs triage',
|
|
||||||
'tenant_prefilter_source' => 'active_tenant_context',
|
|
||||||
'tenant_label' => $tenantB->name,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$component->callAction('clear_tenant_filter')
|
|
||||||
->assertCanSeeTableRecords([$tenantBTriage, $tenantATriage], inOrder: true)
|
|
||||||
->assertCanNotSeeTableRecords([$tenantBBacklog]);
|
|
||||||
|
|
||||||
expect($component->instance()->appliedScope())->toBe([
|
|
||||||
'workspace_scoped' => true,
|
|
||||||
'fixed_scope' => 'visible_unassigned_open_findings_only',
|
|
||||||
'queue_view' => 'needs_triage',
|
|
||||||
'queue_view_label' => 'Needs triage',
|
|
||||||
'tenant_prefilter_source' => 'none',
|
|
||||||
'tenant_label' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$queueViews = collect($component->instance()->queueViews())->keyBy('key');
|
|
||||||
|
|
||||||
expect($queueViews['unassigned']['active'])->toBeFalse()
|
|
||||||
->and($queueViews['needs_triage']['active'])->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('separates needs triage from the remaining backlog and keeps deterministic urgency ordering', function (): void {
|
|
||||||
[$user, $tenant] = findingsIntakeActingUser();
|
|
||||||
|
|
||||||
$overdue = makeIntakeFinding($tenant, [
|
|
||||||
'subject_external_id' => 'overdue',
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'due_at' => now()->subDay(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$reopened = makeIntakeFinding($tenant, [
|
|
||||||
'subject_external_id' => 'reopened',
|
|
||||||
'status' => Finding::STATUS_REOPENED,
|
|
||||||
'reopened_at' => now()->subHours(2),
|
|
||||||
'due_at' => now()->addDay(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$newFinding = makeIntakeFinding($tenant, [
|
|
||||||
'subject_external_id' => 'new-finding',
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
'due_at' => now()->addDays(2),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$remainingBacklog = makeIntakeFinding($tenant, [
|
|
||||||
'subject_external_id' => 'remaining-backlog',
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'due_at' => now()->addHours(12),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$undatedBacklog = makeIntakeFinding($tenant, [
|
|
||||||
'subject_external_id' => 'undated-backlog',
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'due_at' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
findingsIntakePage($user)
|
|
||||||
->assertCanSeeTableRecords([$overdue, $reopened, $newFinding, $remainingBacklog, $undatedBacklog], inOrder: true);
|
|
||||||
|
|
||||||
findingsIntakePage($user, ['view' => 'needs_triage'])
|
|
||||||
->assertCanSeeTableRecords([$reopened, $newFinding], inOrder: true)
|
|
||||||
->assertCanNotSeeTableRecords([$overdue, $remainingBacklog, $undatedBacklog]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds tenant detail drilldowns with intake continuity', function (): void {
|
|
||||||
[$user, $tenant] = findingsIntakeActingUser();
|
|
||||||
|
|
||||||
$finding = makeIntakeFinding($tenant, [
|
|
||||||
'subject_external_id' => 'continuity',
|
|
||||||
'status' => Finding::STATUS_NEW,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$component = findingsIntakePage($user, [
|
|
||||||
'tenant' => (string) $tenant->external_id,
|
|
||||||
'view' => 'needs_triage',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$detailUrl = $component->instance()->getTable()->getRecordUrl($finding);
|
|
||||||
|
|
||||||
expect($detailUrl)->toContain(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
|
|
||||||
->and($detailUrl)->toContain('nav%5Bback_label%5D=Back+to+findings+intake');
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
|
|
||||||
->get($detailUrl)
|
|
||||||
->assertOk()
|
|
||||||
->assertSee('Back to findings intake');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders both intake empty-state branches with the correct single recovery action', function (): void {
|
|
||||||
[$user, $tenantA] = findingsIntakeActingUser();
|
|
||||||
|
|
||||||
$tenantB = Tenant::factory()->create([
|
|
||||||
'status' => 'active',
|
|
||||||
'workspace_id' => (int) $tenantA->workspace_id,
|
|
||||||
'name' => 'Work Tenant',
|
|
||||||
]);
|
|
||||||
createUserWithTenant($tenantB, $user, role: 'readonly', workspaceRole: 'readonly');
|
|
||||||
|
|
||||||
makeIntakeFinding($tenantB, [
|
|
||||||
'subject_external_id' => 'available-elsewhere',
|
|
||||||
]);
|
|
||||||
|
|
||||||
findingsIntakePage($user, [
|
|
||||||
'tenant' => (string) $tenantA->external_id,
|
|
||||||
])
|
|
||||||
->assertSee('No intake findings match this tenant scope')
|
|
||||||
->assertTableEmptyStateActionsExistInOrder(['clear_tenant_filter_empty']);
|
|
||||||
|
|
||||||
Finding::query()->delete();
|
|
||||||
session()->forget(WorkspaceContext::LAST_TENANT_IDS_SESSION_KEY);
|
|
||||||
Filament::setTenant(null, true);
|
|
||||||
|
|
||||||
findingsIntakePage($user)
|
|
||||||
->assertSee('Shared intake is clear')
|
|
||||||
->assertTableEmptyStateActionsExistInOrder(['open_my_findings_empty']);
|
|
||||||
});
|
|
||||||
@ -1,249 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\AlertRule;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Notifications\Findings\FindingEventNotification;
|
|
||||||
use App\Services\Findings\FindingNotificationService;
|
|
||||||
use App\Services\Findings\FindingWorkflowService;
|
|
||||||
use App\Support\OperationRunOutcome;
|
|
||||||
use App\Support\OperationRunStatus;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
|
||||||
function spec230ExpectedNotificationIcon(string $status): string
|
|
||||||
{
|
|
||||||
return (string) data_get(
|
|
||||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
|
||||||
'icon',
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function latestFindingNotificationFor(User $user): ?\Illuminate\Notifications\DatabaseNotification
|
|
||||||
{
|
|
||||||
return $user->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
function findingNotificationCountFor(User $user, string $eventType): int
|
|
||||||
{
|
|
||||||
return $user->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->get()
|
|
||||||
->filter(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === $eventType)
|
|
||||||
->count();
|
|
||||||
}
|
|
||||||
|
|
||||||
function runEvaluateAlertsForWorkspace(int $workspaceId): void
|
|
||||||
{
|
|
||||||
$operationRun = OperationRun::factory()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'tenant_id' => null,
|
|
||||||
'type' => 'alerts.evaluate',
|
|
||||||
'status' => OperationRunStatus::Queued->value,
|
|
||||||
'outcome' => OperationRunOutcome::Pending->value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$job = new \App\Jobs\Alerts\EvaluateAlertsJob($workspaceId, (int) $operationRun->getKey());
|
|
||||||
$job->handle(
|
|
||||||
app(\App\Services\Alerts\AlertDispatchService::class),
|
|
||||||
app(\App\Services\OperationRunService::class),
|
|
||||||
app(FindingNotificationService::class),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('emits assignment notifications only when a new assignee is committed', function (): void {
|
|
||||||
[$owner, $tenant] = $this->actingAsFindingOperator();
|
|
||||||
$firstAssignee = User::factory()->create(['name' => 'First Assignee']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $firstAssignee, role: 'operator');
|
|
||||||
|
|
||||||
$secondAssignee = User::factory()->create(['name' => 'Second Assignee']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $secondAssignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_TRIAGED, [
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$workflow = app(FindingWorkflowService::class);
|
|
||||||
|
|
||||||
$workflow->assign(
|
|
||||||
finding: $finding,
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: $owner,
|
|
||||||
assigneeUserId: (int) $firstAssignee->getKey(),
|
|
||||||
ownerUserId: (int) $owner->getKey(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$firstNotification = latestFindingNotificationFor($firstAssignee);
|
|
||||||
|
|
||||||
expect($firstNotification)->not->toBeNull()
|
|
||||||
->and(data_get($firstNotification?->data, 'title'))->toBe('Finding assigned')
|
|
||||||
->and(data_get($firstNotification?->data, 'status'))->toBe('info')
|
|
||||||
->and(data_get($firstNotification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('info'))
|
|
||||||
->and(data_get($firstNotification?->data, 'actions.0.label'))->toBe('Open finding')
|
|
||||||
->and(data_get($firstNotification?->data, 'actions.0.target'))->toBe('finding_detail')
|
|
||||||
->and(data_get($firstNotification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
|
|
||||||
$workflow->assign(
|
|
||||||
finding: $finding->fresh(),
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: $owner,
|
|
||||||
assigneeUserId: (int) $firstAssignee->getKey(),
|
|
||||||
ownerUserId: (int) $secondAssignee->getKey(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$workflow->assign(
|
|
||||||
finding: $finding->fresh(),
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: $owner,
|
|
||||||
assigneeUserId: null,
|
|
||||||
ownerUserId: (int) $secondAssignee->getKey(),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(findingNotificationCountFor($firstAssignee, AlertRule::EVENT_FINDINGS_ASSIGNED))->toBe(1);
|
|
||||||
|
|
||||||
$workflow->assign(
|
|
||||||
finding: $finding->fresh(),
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: $owner,
|
|
||||||
assigneeUserId: (int) $secondAssignee->getKey(),
|
|
||||||
ownerUserId: (int) $secondAssignee->getKey(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$secondNotification = latestFindingNotificationFor($secondAssignee);
|
|
||||||
|
|
||||||
expect($secondNotification)->not->toBeNull()
|
|
||||||
->and(data_get($secondNotification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED)
|
|
||||||
->and(findingNotificationCountFor($secondAssignee, AlertRule::EVENT_FINDINGS_ASSIGNED))->toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('dedupes repeated reopen dispatches for the same reopen occurrence', function (): void {
|
|
||||||
$now = CarbonImmutable::parse('2026-04-22T09:30:00Z');
|
|
||||||
CarbonImmutable::setTestNow($now);
|
|
||||||
|
|
||||||
[$owner, $tenant] = $this->actingAsFindingOperator();
|
|
||||||
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_RESOLVED, [
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$reopened = app(FindingWorkflowService::class)->reopenBySystem(
|
|
||||||
finding: $finding,
|
|
||||||
tenant: $tenant,
|
|
||||||
reopenedAt: $now,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_REOPENED))->toBe(1);
|
|
||||||
|
|
||||||
app(FindingNotificationService::class)->dispatch($reopened->fresh(), AlertRule::EVENT_FINDINGS_REOPENED);
|
|
||||||
|
|
||||||
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_REOPENED))->toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends due soon and overdue notifications once per due cycle and resets when due_at changes', function (): void {
|
|
||||||
$now = CarbonImmutable::parse('2026-04-22T10:00:00Z');
|
|
||||||
CarbonImmutable::setTestNow($now);
|
|
||||||
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$workspaceId = (int) $tenant->workspace_id;
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
$this->actingAs($owner);
|
|
||||||
|
|
||||||
$assignee = User::factory()->create(['name' => 'Due Soon Operator']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$dueSoonFinding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
'due_at' => $now->addHours(6),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$overdueFinding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => null,
|
|
||||||
'due_at' => $now->subHours(2),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$closedFinding = Finding::factory()->for($tenant)->closed()->create([
|
|
||||||
'workspace_id' => $workspaceId,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
'due_at' => $now->subHours(1),
|
|
||||||
]);
|
|
||||||
|
|
||||||
runEvaluateAlertsForWorkspace($workspaceId);
|
|
||||||
runEvaluateAlertsForWorkspace($workspaceId);
|
|
||||||
|
|
||||||
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_DUE_SOON))->toBe(1)
|
|
||||||
->and(findingNotificationCountFor($owner, AlertRule::EVENT_FINDINGS_OVERDUE))->toBe(1);
|
|
||||||
|
|
||||||
$dueSoonNotification = $assignee->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->get()
|
|
||||||
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_DUE_SOON);
|
|
||||||
$overdueNotification = $owner->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->get()
|
|
||||||
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
|
|
||||||
expect($dueSoonNotification)->not->toBeNull()
|
|
||||||
->and(data_get($dueSoonNotification?->data, 'status'))->toBe('warning')
|
|
||||||
->and(data_get($dueSoonNotification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('warning'))
|
|
||||||
->and(data_get($dueSoonNotification?->data, 'actions.0.label'))->toBe('Open finding');
|
|
||||||
expect($overdueNotification)->not->toBeNull()
|
|
||||||
->and(data_get($overdueNotification?->data, 'status'))->toBe('danger')
|
|
||||||
->and(data_get($overdueNotification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('danger'))
|
|
||||||
->and(data_get($overdueNotification?->data, 'actions.0.label'))->toBe('Open finding');
|
|
||||||
|
|
||||||
expect($assignee->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->get()
|
|
||||||
->contains(fn ($notification): bool => (int) data_get($notification->data, 'finding_event.finding_id') === (int) $closedFinding->getKey()))
|
|
||||||
->toBeFalse();
|
|
||||||
|
|
||||||
$dueSoonFinding->forceFill([
|
|
||||||
'due_at' => $now->addHours(12),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
$overdueFinding->forceFill([
|
|
||||||
'due_at' => $now->addDay()->subHour(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
runEvaluateAlertsForWorkspace($workspaceId);
|
|
||||||
|
|
||||||
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_DUE_SOON))->toBe(2)
|
|
||||||
->and(findingNotificationCountFor($owner, AlertRule::EVENT_FINDINGS_OVERDUE))->toBe(1);
|
|
||||||
|
|
||||||
$dueSoonFinding->forceFill([
|
|
||||||
'due_at' => $now->addDays(5),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
CarbonImmutable::setTestNow($now->addDays(2));
|
|
||||||
$overdueFinding->forceFill([
|
|
||||||
'due_at' => CarbonImmutable::now('UTC')->subHour(),
|
|
||||||
])->save();
|
|
||||||
|
|
||||||
runEvaluateAlertsForWorkspace($workspaceId);
|
|
||||||
|
|
||||||
expect(findingNotificationCountFor($owner, AlertRule::EVENT_FINDINGS_OVERDUE))->toBe(2);
|
|
||||||
});
|
|
||||||
@ -1,226 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\AlertRule;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\TenantMembership;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Notifications\Findings\FindingEventNotification;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Findings\FindingNotificationService;
|
|
||||||
use App\Services\Findings\FindingWorkflowService;
|
|
||||||
use Carbon\CarbonImmutable;
|
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
CarbonImmutable::setTestNow();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
|
||||||
function spec230ExpectedNotificationIcon(string $status): string
|
|
||||||
{
|
|
||||||
return (string) data_get(
|
|
||||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
|
||||||
'icon',
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dispatchedFindingNotificationsFor(User $user): \Illuminate\Support\Collection
|
|
||||||
{
|
|
||||||
return $user->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->orderBy('id')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
it('uses the documented recipient precedence for assignment reopen due soon and overdue', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$service = app(FindingNotificationService::class);
|
|
||||||
|
|
||||||
$assignee = User::factory()->create(['name' => 'Assignee']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
'due_at' => now()->addHours(6),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_REOPENED);
|
|
||||||
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
|
||||||
$service->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
|
|
||||||
expect(dispatchedFindingNotificationsFor($assignee)
|
|
||||||
->pluck('data.finding_event.event_type')
|
|
||||||
->all())
|
|
||||||
->toContain(AlertRule::EVENT_FINDINGS_ASSIGNED, AlertRule::EVENT_FINDINGS_REOPENED, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
|
||||||
|
|
||||||
expect(dispatchedFindingNotificationsFor($owner)
|
|
||||||
->pluck('data.finding_event.event_type')
|
|
||||||
->all())
|
|
||||||
->toContain(AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
|
|
||||||
$assignedNotification = dispatchedFindingNotificationsFor($assignee)
|
|
||||||
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
$overdueNotification = dispatchedFindingNotificationsFor($owner)
|
|
||||||
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
|
|
||||||
expect($assignedNotification)->not->toBeNull()
|
|
||||||
->and(data_get($assignedNotification?->data, 'status'))->toBe('info')
|
|
||||||
->and(data_get($assignedNotification?->data, 'actions.0.target'))->toBe('finding_detail');
|
|
||||||
expect($overdueNotification)->not->toBeNull()
|
|
||||||
->and(data_get($overdueNotification?->data, 'status'))->toBe('danger')
|
|
||||||
->and(data_get($overdueNotification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('danger'))
|
|
||||||
->and(data_get($overdueNotification?->data, 'actions.0.target'))->toBe('finding_detail');
|
|
||||||
|
|
||||||
$fallbackFinding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_REOPENED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => null,
|
|
||||||
'due_at' => now()->addHours(2),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$service->dispatch($fallbackFinding, AlertRule::EVENT_FINDINGS_REOPENED);
|
|
||||||
$service->dispatch($fallbackFinding, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
|
||||||
|
|
||||||
$ownerEventTypes = dispatchedFindingNotificationsFor($owner)
|
|
||||||
->pluck('data.finding_event.event_type')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
expect($ownerEventTypes)->toContain(AlertRule::EVENT_FINDINGS_REOPENED, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('suppresses direct delivery when the preferred recipient loses tenant access', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = User::factory()->create(['name' => 'Removed Assignee']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_REOPENED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
TenantMembership::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('user_id', (int) $assignee->getKey())
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
app(CapabilityResolver::class)->clearCache();
|
|
||||||
|
|
||||||
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_REOPENED);
|
|
||||||
|
|
||||||
expect($result['direct_delivery_status'])->toBe('suppressed')
|
|
||||||
->and(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
|
|
||||||
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not broaden delivery to the owner when the assignee is present but no longer entitled', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = User::factory()->create(['name' => 'Current Assignee']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
'due_at' => now()->addHours(3),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$resolver = \Mockery::mock(CapabilityResolver::class);
|
|
||||||
$resolver->shouldReceive('isMember')->andReturnTrue();
|
|
||||||
$resolver->shouldReceive('can')
|
|
||||||
->andReturnUsing(function (User $user): bool {
|
|
||||||
return $user->name !== 'Current Assignee';
|
|
||||||
});
|
|
||||||
app()->instance(CapabilityResolver::class, $resolver);
|
|
||||||
|
|
||||||
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_DUE_SOON);
|
|
||||||
|
|
||||||
expect($result['direct_delivery_status'])->toBe('suppressed')
|
|
||||||
->and(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
|
|
||||||
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('suppresses owner-only assignment edits and assignee clears from creating direct notifications', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$replacementOwner = User::factory()->create(['name' => 'Replacement Owner']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $replacementOwner, role: 'manager');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$workflow = app(FindingWorkflowService::class);
|
|
||||||
|
|
||||||
$workflow->assign(
|
|
||||||
finding: $finding,
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: $owner,
|
|
||||||
assigneeUserId: (int) $assignee->getKey(),
|
|
||||||
ownerUserId: (int) $replacementOwner->getKey(),
|
|
||||||
);
|
|
||||||
|
|
||||||
$workflow->assign(
|
|
||||||
finding: $finding->fresh(),
|
|
||||||
tenant: $tenant,
|
|
||||||
actor: $owner,
|
|
||||||
assigneeUserId: null,
|
|
||||||
ownerUserId: (int) $replacementOwner->getKey(),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(dispatchedFindingNotificationsFor($assignee))->toHaveCount(0)
|
|
||||||
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0)
|
|
||||||
->and(dispatchedFindingNotificationsFor($replacementOwner))->toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('suppresses due notifications for terminal findings', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->closed()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $owner->getKey(),
|
|
||||||
'due_at' => now()->subHour(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
|
|
||||||
expect($result['direct_delivery_status'])->toBe('suppressed')
|
|
||||||
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends one direct notification when owner and assignee are the same entitled user', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_IN_PROGRESS,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $owner->getKey(),
|
|
||||||
'due_at' => now()->subHour(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$result = app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_OVERDUE);
|
|
||||||
|
|
||||||
expect($result['direct_delivery_status'])->toBe('sent')
|
|
||||||
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(1)
|
|
||||||
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.finding_event.recipient_reason'))->toBe('current_owner')
|
|
||||||
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.status'))->toBe('danger')
|
|
||||||
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.actions.0.label'))->toBe('Open finding');
|
|
||||||
});
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Filament\Resources\FindingResource;
|
|
||||||
use App\Models\AlertRule;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\TenantMembership;
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Notifications\Findings\FindingEventNotification;
|
|
||||||
use App\Services\Auth\CapabilityResolver;
|
|
||||||
use App\Services\Findings\FindingNotificationService;
|
|
||||||
use App\Support\Auth\Capabilities;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
|
|
||||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
|
||||||
function spec230ExpectedNotificationIcon(string $status): string
|
|
||||||
{
|
|
||||||
return (string) data_get(
|
|
||||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
|
||||||
'icon',
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('stores a filament payload with one tenant finding deep link and recipient reason copy', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
|
|
||||||
$notification = $assignee->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
expect($notification)->not->toBeNull()
|
|
||||||
->and(data_get($notification?->data, 'format'))->toBe('filament')
|
|
||||||
->and(data_get($notification?->data, 'title'))->toBe('Finding assigned')
|
|
||||||
->and(data_get($notification?->data, 'status'))->toBe('info')
|
|
||||||
->and(data_get($notification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('info'))
|
|
||||||
->and(data_get($notification?->data, 'actions.0.label'))->toBe('Open finding')
|
|
||||||
->and(data_get($notification?->data, 'actions.0.url'))
|
|
||||||
->toBe(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
|
|
||||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('finding_detail')
|
|
||||||
->and(data_get($notification?->data, 'supporting_lines'))->toBe(['You are the new assignee.'])
|
|
||||||
->and(data_get($notification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED)
|
|
||||||
->and(data_get($notification?->data, 'finding_event.recipient_reason'))->toBe('new_assignee');
|
|
||||||
|
|
||||||
$this->actingAs($assignee);
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
$this->get(data_get($notification?->data, 'actions.0.url'))->assertSuccessful();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps due soon and overdue finding notifications onto the shared status and icon treatment', function (
|
|
||||||
string $eventType,
|
|
||||||
string $recipient,
|
|
||||||
string $expectedStatus,
|
|
||||||
string $findingStatus,
|
|
||||||
string $relativeDueAt,
|
|
||||||
): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = User::factory()->create(['name' => 'Urgency Operator']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => $findingStatus,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
'due_at' => now()->modify($relativeDueAt),
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(FindingNotificationService::class)->dispatch($finding, $eventType);
|
|
||||||
|
|
||||||
$notifiable = $recipient === 'owner' ? $owner : $assignee;
|
|
||||||
$notification = $notifiable->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
expect($notification)->not->toBeNull()
|
|
||||||
->and(data_get($notification?->data, 'status'))->toBe($expectedStatus)
|
|
||||||
->and(data_get($notification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon($expectedStatus))
|
|
||||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('finding_detail');
|
|
||||||
})->with([
|
|
||||||
'due soon' => [AlertRule::EVENT_FINDINGS_DUE_SOON, 'assignee', 'warning', Finding::STATUS_TRIAGED, '+6 hours'],
|
|
||||||
'overdue' => [AlertRule::EVENT_FINDINGS_OVERDUE, 'owner', 'danger', Finding::STATUS_IN_PROGRESS, '-2 hours'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
it('returns 404 when a finding notification link is opened after tenant access is removed', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = User::factory()->create(['name' => 'Removed Operator']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
|
|
||||||
$url = data_get($assignee->notifications()->latest('id')->first(), 'data.actions.0.url');
|
|
||||||
|
|
||||||
TenantMembership::query()
|
|
||||||
->where('tenant_id', (int) $tenant->getKey())
|
|
||||||
->where('user_id', (int) $assignee->getKey())
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
app(CapabilityResolver::class)->clearCache();
|
|
||||||
|
|
||||||
$this->actingAs($assignee)
|
|
||||||
->get($url)
|
|
||||||
->assertNotFound();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 403 when a finding notification link is opened by an in-scope member without findings view capability', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = User::factory()->create(['name' => 'Scoped Operator']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
|
|
||||||
$url = data_get($assignee->notifications()->latest('id')->first(), 'data.actions.0.url');
|
|
||||||
|
|
||||||
Gate::define(Capabilities::TENANT_FINDINGS_VIEW, fn (): bool => false);
|
|
||||||
|
|
||||||
$this->actingAs($assignee);
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
$this->get($url)->assertForbidden();
|
|
||||||
});
|
|
||||||
@ -1,26 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\PlatformUser;
|
|
||||||
use App\Notifications\OperationRunCompleted;
|
use App\Notifications\OperationRunCompleted;
|
||||||
use App\Notifications\OperationRunQueued;
|
use App\Notifications\OperationRunQueued;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\Auth\PlatformCapabilities;
|
|
||||||
use App\Support\System\SystemOperationRunLinks;
|
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
|
||||||
|
|
||||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
|
||||||
function spec230ExpectedNotificationIcon(string $status): string
|
|
||||||
{
|
|
||||||
return (string) data_get(
|
|
||||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
|
||||||
'icon',
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('emits a queued notification after successful dispatch (initiator only) with view link', function () {
|
it('emits a queued notification after successful dispatch (initiator only) with view link', function () {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
@ -52,16 +37,8 @@ function spec230ExpectedNotificationIcon(string $status): string
|
|||||||
$notification = $user->notifications()->latest('id')->first();
|
$notification = $user->notifications()->latest('id')->first();
|
||||||
expect($notification)->not->toBeNull();
|
expect($notification)->not->toBeNull();
|
||||||
expect($notification->data['body'] ?? null)->toBe('Queued for execution. Open the operation for progress and next steps.');
|
expect($notification->data['body'] ?? null)->toBe('Queued for execution. Open the operation for progress and next steps.');
|
||||||
expect(data_get($notification->data, 'status'))->toBe('info');
|
|
||||||
expect(data_get($notification->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('info'));
|
|
||||||
expect(data_get($notification->data, 'actions'))->toHaveCount(1);
|
|
||||||
expect(data_get($notification->data, 'actions.0.label'))->toBe(OperationRunLinks::openLabel());
|
|
||||||
expect($notification->data['actions'][0]['url'] ?? null)
|
expect($notification->data['actions'][0]['url'] ?? null)
|
||||||
->toBe(OperationRunLinks::view($run, $tenant));
|
->toBe(OperationRunLinks::view($run, $tenant));
|
||||||
expect(data_get($notification->data, 'actions.0.target'))->toBe('admin_operation_run');
|
|
||||||
expect(array_values(data_get($notification->data, 'supporting_lines', [])))->toBe([]);
|
|
||||||
|
|
||||||
$this->get(data_get($notification->data, 'actions.0.url'))->assertSuccessful();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not emit queued notifications for runs without an initiator', function () {
|
it('does not emit queued notifications for runs without an initiator', function () {
|
||||||
@ -111,36 +88,8 @@ function spec230ExpectedNotificationIcon(string $status): string
|
|||||||
$notification = $user->notifications()->latest('id')->first();
|
$notification = $user->notifications()->latest('id')->first();
|
||||||
expect($notification)->not->toBeNull();
|
expect($notification)->not->toBeNull();
|
||||||
expect($notification->data['body'] ?? null)->toBe('Queued for execution. Open the operation for progress and next steps.');
|
expect($notification->data['body'] ?? null)->toBe('Queued for execution. Open the operation for progress and next steps.');
|
||||||
expect(data_get($notification->data, 'status'))->toBe('info');
|
|
||||||
expect(data_get($notification->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('info'));
|
|
||||||
expect($notification->data['actions'][0]['url'] ?? null)
|
expect($notification->data['actions'][0]['url'] ?? null)
|
||||||
->toBe(OperationRunLinks::tenantlessView($run));
|
->toBe(OperationRunLinks::tenantlessView($run));
|
||||||
expect(data_get($notification->data, 'actions.0.target'))->toBe('tenantless_operation_run');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses a tenantless view link for queued tenantless runs', function () {
|
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$this->actingAs($user);
|
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_id' => null,
|
|
||||||
'user_id' => (int) $user->getKey(),
|
|
||||||
'initiator_name' => $user->name,
|
|
||||||
'type' => 'provider.connection.check',
|
|
||||||
'status' => 'queued',
|
|
||||||
'outcome' => 'pending',
|
|
||||||
'context' => [],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user->notify(new OperationRunQueued($run));
|
|
||||||
|
|
||||||
$notification = $user->notifications()->latest('id')->first();
|
|
||||||
expect($notification)->not->toBeNull()
|
|
||||||
->and(data_get($notification?->data, 'actions.0.url'))->toBe(OperationRunLinks::tenantlessView($run))
|
|
||||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('tenantless_operation_run');
|
|
||||||
|
|
||||||
$this->get(data_get($notification?->data, 'actions.0.url'))->assertSuccessful();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits a terminal notification when an operation run transitions to completed', function () {
|
it('emits a terminal notification when an operation run transitions to completed', function () {
|
||||||
@ -182,15 +131,8 @@ function spec230ExpectedNotificationIcon(string $status): string
|
|||||||
expect($notification)->not->toBeNull();
|
expect($notification)->not->toBeNull();
|
||||||
expect($notification->data['body'] ?? null)->toContain('Completed successfully.');
|
expect($notification->data['body'] ?? null)->toContain('Completed successfully.');
|
||||||
expect($notification->data['body'] ?? null)->toContain('No action needed.');
|
expect($notification->data['body'] ?? null)->toContain('No action needed.');
|
||||||
expect(data_get($notification->data, 'status'))->toBe('success');
|
|
||||||
expect(data_get($notification->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('success'));
|
|
||||||
expect(data_get($notification->data, 'actions'))->toHaveCount(1);
|
|
||||||
expect(array_values(data_get($notification->data, 'supporting_lines', [])))->toContain('No action needed.', 'Total: 1');
|
|
||||||
expect($notification->data['actions'][0]['url'] ?? null)
|
expect($notification->data['actions'][0]['url'] ?? null)
|
||||||
->toBe(OperationRunLinks::view($run, $tenant));
|
->toBe(OperationRunLinks::view($run, $tenant));
|
||||||
expect(data_get($notification->data, 'actions.0.target'))->toBe('admin_operation_run');
|
|
||||||
|
|
||||||
$this->get(data_get($notification->data, 'actions.0.url'))->assertSuccessful();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses a tenantless view link for completed tenantless runs', function () {
|
it('uses a tenantless view link for completed tenantless runs', function () {
|
||||||
@ -226,42 +168,7 @@ function spec230ExpectedNotificationIcon(string $status): string
|
|||||||
->and($notification->data['body'] ?? null)->toContain('Execution prerequisite changed')
|
->and($notification->data['body'] ?? null)->toContain('Execution prerequisite changed')
|
||||||
->and($notification->data['body'] ?? null)->toContain('queued execution prerequisites are no longer satisfied')
|
->and($notification->data['body'] ?? null)->toContain('queued execution prerequisites are no longer satisfied')
|
||||||
->and($notification->data['body'] ?? null)->not->toContain('execution_prerequisite_invalid')
|
->and($notification->data['body'] ?? null)->not->toContain('execution_prerequisite_invalid')
|
||||||
->and($notification->data['actions'][0]['url'] ?? null)->toBe(OperationRunLinks::tenantlessView($run))
|
->and($notification->data['actions'][0]['url'] ?? null)->toBe(OperationRunLinks::tenantlessView($run));
|
||||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('tenantless_operation_run');
|
|
||||||
|
|
||||||
$this->get(data_get($notification?->data, 'actions.0.url'))->assertSuccessful();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the system operation route for completed notifications delivered to platform users', function (): void {
|
|
||||||
$platformUser = PlatformUser::factory()->create([
|
|
||||||
'capabilities' => [
|
|
||||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
|
||||||
PlatformCapabilities::OPERATIONS_VIEW,
|
|
||||||
],
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$run = OperationRun::factory()->create([
|
|
||||||
'tenant_id' => null,
|
|
||||||
'type' => 'inventory_sync',
|
|
||||||
'status' => 'completed',
|
|
||||||
'outcome' => 'succeeded',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$platformUser->notify(new OperationRunCompleted($run));
|
|
||||||
|
|
||||||
$notification = $platformUser->notifications()->latest('id')->first();
|
|
||||||
|
|
||||||
expect($notification)->not->toBeNull()
|
|
||||||
->and(data_get($notification?->data, 'status'))->toBe('success')
|
|
||||||
->and(data_get($notification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('success'))
|
|
||||||
->and(data_get($notification?->data, 'actions.0.label'))->toBe(OperationRunLinks::openLabel())
|
|
||||||
->and(data_get($notification?->data, 'actions.0.url'))->toBe(SystemOperationRunLinks::view($run))
|
|
||||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('system_operation_run');
|
|
||||||
|
|
||||||
$this->actingAs($platformUser, 'platform')
|
|
||||||
->get(SystemOperationRunLinks::view($run))
|
|
||||||
->assertSuccessful();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders partial backup-set update notifications with RBAC foundation summary counts', function () {
|
it('renders partial backup-set update notifications with RBAC foundation summary counts', function () {
|
||||||
|
|||||||
@ -1,229 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
use App\Models\AlertRule;
|
|
||||||
use App\Models\Finding;
|
|
||||||
use App\Models\OperationRun;
|
|
||||||
use App\Notifications\Findings\FindingEventNotification;
|
|
||||||
use App\Notifications\OperationRunCompleted;
|
|
||||||
use App\Notifications\OperationRunQueued;
|
|
||||||
use App\Services\Findings\FindingNotificationService;
|
|
||||||
use App\Services\OperationRunService;
|
|
||||||
use Filament\Facades\Filament;
|
|
||||||
use Filament\Notifications\Notification as FilamentNotification;
|
|
||||||
|
|
||||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
|
||||||
function spec230ExpectedNotificationIcon(string $status): string
|
|
||||||
{
|
|
||||||
return (string) data_get(
|
|
||||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
|
||||||
'icon',
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! function_exists('spec230AssertSharedNotificationPayload')) {
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $payload
|
|
||||||
* @param array{
|
|
||||||
* title: string,
|
|
||||||
* status: string,
|
|
||||||
* actionLabel: string,
|
|
||||||
* actionTarget: string,
|
|
||||||
* supportingLines: list<string>,
|
|
||||||
* primaryBody: string
|
|
||||||
* } $expected
|
|
||||||
*/
|
|
||||||
function spec230AssertSharedNotificationPayload(array $payload, array $expected): void
|
|
||||||
{
|
|
||||||
expect(data_get($payload, 'format'))->toBe('filament')
|
|
||||||
->and((string) data_get($payload, 'title'))->toBe($expected['title'])
|
|
||||||
->and((string) data_get($payload, 'body'))->toStartWith($expected['primaryBody'])
|
|
||||||
->and(data_get($payload, 'status'))->toBe($expected['status'])
|
|
||||||
->and(data_get($payload, 'icon'))->toBe(spec230ExpectedNotificationIcon($expected['status']))
|
|
||||||
->and(data_get($payload, 'actions', []))->toHaveCount(1)
|
|
||||||
->and(data_get($payload, 'actions.0.label'))->toBe($expected['actionLabel'])
|
|
||||||
->and(data_get($payload, 'actions.0.target'))->toBe($expected['actionTarget'])
|
|
||||||
->and(array_values(data_get($payload, 'supporting_lines', [])))->toBe($expected['supportingLines']);
|
|
||||||
|
|
||||||
foreach ($expected['supportingLines'] as $line) {
|
|
||||||
expect((string) data_get($payload, 'body'))->toContain($line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('enforces the shared database notification contract across finding queued and completed consumers', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = \App\Models\User::factory()->create(['name' => 'Shared Contract Assignee']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$this->actingAs($owner);
|
|
||||||
$tenant->makeCurrent();
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'severity' => 'high',
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
|
|
||||||
$queuedRun = app(OperationRunService::class)->ensureRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
type: 'policy.sync',
|
|
||||||
inputs: ['scope' => 'all'],
|
|
||||||
initiator: $owner,
|
|
||||||
);
|
|
||||||
|
|
||||||
app(OperationRunService::class)->dispatchOrFail($queuedRun, function (): void {
|
|
||||||
// no-op
|
|
||||||
}, emitQueuedNotification: true);
|
|
||||||
|
|
||||||
$completedRun = OperationRun::factory()->create([
|
|
||||||
'tenant_id' => (int) $tenant->getKey(),
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'user_id' => (int) $owner->getKey(),
|
|
||||||
'initiator_name' => $owner->name,
|
|
||||||
'type' => 'inventory_sync',
|
|
||||||
'status' => 'queued',
|
|
||||||
'outcome' => 'pending',
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(OperationRunService::class)->updateRun(
|
|
||||||
$completedRun,
|
|
||||||
status: 'completed',
|
|
||||||
outcome: 'succeeded',
|
|
||||||
summaryCounts: ['total' => 1],
|
|
||||||
failures: [],
|
|
||||||
);
|
|
||||||
|
|
||||||
$findingNotification = $assignee->notifications()
|
|
||||||
->where('type', FindingEventNotification::class)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
$queuedNotification = $owner->notifications()
|
|
||||||
->where('type', OperationRunQueued::class)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
$completedNotification = $owner->notifications()
|
|
||||||
->where('type', OperationRunCompleted::class)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
expect($findingNotification)->not->toBeNull();
|
|
||||||
expect($queuedNotification)->not->toBeNull();
|
|
||||||
expect($completedNotification)->not->toBeNull();
|
|
||||||
|
|
||||||
spec230AssertSharedNotificationPayload($findingNotification?->data ?? [], [
|
|
||||||
'title' => 'Finding assigned',
|
|
||||||
'primaryBody' => 'Finding #'.(int) $finding->getKey().' in '.$tenant->getFilamentName().' was assigned. High severity.',
|
|
||||||
'status' => 'info',
|
|
||||||
'actionLabel' => 'Open finding',
|
|
||||||
'actionTarget' => 'finding_detail',
|
|
||||||
'supportingLines' => ['You are the new assignee.'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
spec230AssertSharedNotificationPayload($queuedNotification?->data ?? [], [
|
|
||||||
'title' => 'Policy sync queued',
|
|
||||||
'primaryBody' => 'Queued for execution. Open the operation for progress and next steps.',
|
|
||||||
'status' => 'info',
|
|
||||||
'actionLabel' => 'Open operation',
|
|
||||||
'actionTarget' => 'admin_operation_run',
|
|
||||||
'supportingLines' => [],
|
|
||||||
]);
|
|
||||||
|
|
||||||
spec230AssertSharedNotificationPayload($completedNotification?->data ?? [], [
|
|
||||||
'title' => 'Inventory sync completed successfully',
|
|
||||||
'primaryBody' => 'Completed successfully.',
|
|
||||||
'status' => 'success',
|
|
||||||
'actionLabel' => 'Open operation',
|
|
||||||
'actionTarget' => 'admin_operation_run',
|
|
||||||
'supportingLines' => ['No action needed.', 'Total: 1'],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps exactly one primary action and preserves secondary metadata boundaries across in-scope consumers', function (): void {
|
|
||||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
|
||||||
$assignee = \App\Models\User::factory()->create(['name' => 'Boundary Assignee']);
|
|
||||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
|
||||||
|
|
||||||
$this->actingAs($owner);
|
|
||||||
$tenant->makeCurrent();
|
|
||||||
Filament::setTenant($tenant, true);
|
|
||||||
|
|
||||||
$finding = Finding::factory()->for($tenant)->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'status' => Finding::STATUS_TRIAGED,
|
|
||||||
'owner_user_id' => (int) $owner->getKey(),
|
|
||||||
'assignee_user_id' => (int) $assignee->getKey(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
|
||||||
|
|
||||||
$tenantlessQueuedRun = OperationRun::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_id' => null,
|
|
||||||
'user_id' => (int) $owner->getKey(),
|
|
||||||
'initiator_name' => $owner->name,
|
|
||||||
'type' => 'provider.connection.check',
|
|
||||||
'status' => 'queued',
|
|
||||||
'outcome' => 'pending',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$owner->notify(new OperationRunQueued($tenantlessQueuedRun));
|
|
||||||
|
|
||||||
$tenantlessCompletedRun = OperationRun::factory()->create([
|
|
||||||
'workspace_id' => (int) $tenant->workspace_id,
|
|
||||||
'tenant_id' => null,
|
|
||||||
'user_id' => (int) $owner->getKey(),
|
|
||||||
'initiator_name' => $owner->name,
|
|
||||||
'type' => 'provider.connection.check',
|
|
||||||
'status' => 'queued',
|
|
||||||
'outcome' => 'pending',
|
|
||||||
]);
|
|
||||||
|
|
||||||
app(OperationRunService::class)->updateRun(
|
|
||||||
$tenantlessCompletedRun,
|
|
||||||
status: 'completed',
|
|
||||||
outcome: 'blocked',
|
|
||||||
failures: [[
|
|
||||||
'code' => 'operation.blocked',
|
|
||||||
'reason_code' => 'execution_prerequisite_invalid',
|
|
||||||
'message' => 'Operation blocked because the queued execution prerequisites are no longer satisfied.',
|
|
||||||
]],
|
|
||||||
);
|
|
||||||
|
|
||||||
$findingPayload = data_get(
|
|
||||||
$assignee->notifications()->where('type', FindingEventNotification::class)->latest('id')->first(),
|
|
||||||
'data',
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
$queuedPayload = data_get(
|
|
||||||
$owner->notifications()->where('type', OperationRunQueued::class)->latest('id')->first(),
|
|
||||||
'data',
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
$completedPayload = data_get(
|
|
||||||
$owner->notifications()->where('type', OperationRunCompleted::class)->latest('id')->first(),
|
|
||||||
'data',
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(data_get($findingPayload, 'actions', []))->toHaveCount(1)
|
|
||||||
->and(data_get($queuedPayload, 'actions', []))->toHaveCount(1)
|
|
||||||
->and(data_get($completedPayload, 'actions', []))->toHaveCount(1)
|
|
||||||
->and(data_get($findingPayload, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED)
|
|
||||||
->and(data_get($findingPayload, 'reason_translation'))->toBeNull()
|
|
||||||
->and(data_get($queuedPayload, 'finding_event'))->toBeNull()
|
|
||||||
->and(data_get($queuedPayload, 'reason_translation'))->toBeNull()
|
|
||||||
->and(data_get($completedPayload, 'finding_event'))->toBeNull()
|
|
||||||
->and(data_get($completedPayload, 'reason_translation.operator_label'))->toBe('Execution prerequisite changed')
|
|
||||||
->and(data_get($completedPayload, 'actions.0.target'))->toBe('tenantless_operation_run')
|
|
||||||
->and(array_values(data_get($queuedPayload, 'supporting_lines', [])))->toBe([])
|
|
||||||
->and(array_values(data_get($completedPayload, 'supporting_lines', [])))->toContain('Execution prerequisite changed');
|
|
||||||
});
|
|
||||||
@ -52,67 +52,3 @@
|
|||||||
|
|
||||||
expect($violations)->toBe([]);
|
expect($violations)->toBe([]);
|
||||||
})->group('ops-ux');
|
})->group('ops-ux');
|
||||||
|
|
||||||
it('keeps in-scope database notifications routed through the shared presenter seam', function (): void {
|
|
||||||
$root = SourceFileScanner::projectRoot();
|
|
||||||
$files = [
|
|
||||||
$root.'/app/Notifications/Findings/FindingEventNotification.php',
|
|
||||||
$root.'/app/Notifications/OperationRunQueued.php',
|
|
||||||
$root.'/app/Notifications/OperationRunCompleted.php',
|
|
||||||
];
|
|
||||||
$needles = [
|
|
||||||
'FilamentNotification::make(',
|
|
||||||
'->getDatabaseMessage(',
|
|
||||||
];
|
|
||||||
$violations = [];
|
|
||||||
|
|
||||||
foreach ($files as $file) {
|
|
||||||
$source = SourceFileScanner::read($file);
|
|
||||||
|
|
||||||
foreach ($needles as $needle) {
|
|
||||||
if (! str_contains($source, $needle)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$offset = 0;
|
|
||||||
|
|
||||||
while (($position = strpos($source, $needle, $offset)) !== false) {
|
|
||||||
$line = substr_count(substr($source, 0, $position), "\n") + 1;
|
|
||||||
|
|
||||||
$violations[] = [
|
|
||||||
'file' => SourceFileScanner::relativePath($file),
|
|
||||||
'line' => $line,
|
|
||||||
'snippet' => SourceFileScanner::snippet($source, $line),
|
|
||||||
];
|
|
||||||
|
|
||||||
$offset = $position + strlen($needle);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($violations !== []) {
|
|
||||||
$messages = array_map(static function (array $violation): string {
|
|
||||||
return sprintf(
|
|
||||||
"%s:%d\n%s",
|
|
||||||
$violation['file'],
|
|
||||||
$violation['line'],
|
|
||||||
$violation['snippet'],
|
|
||||||
);
|
|
||||||
}, $violations);
|
|
||||||
|
|
||||||
$this->fail(
|
|
||||||
"Local database-notification payload composition found in in-scope consumers:\n\n".implode("\n\n", $messages)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect($violations)->toBe([]);
|
|
||||||
})->group('ops-ux');
|
|
||||||
|
|
||||||
it('keeps alert email delivery outside the shared database notification contract boundary', function (): void {
|
|
||||||
$source = SourceFileScanner::read(
|
|
||||||
SourceFileScanner::projectRoot().'/app/Notifications/Alerts/EmailAlertNotification.php'
|
|
||||||
);
|
|
||||||
|
|
||||||
expect($source)->not->toContain('OperationUxPresenter')
|
|
||||||
->and($source)->not->toContain('FilamentNotification');
|
|
||||||
})->group('ops-ux');
|
|
||||||
|
|||||||
@ -3,7 +3,7 @@ # Product Roadmap
|
|||||||
> Strategic thematic blocks and release trajectory.
|
> Strategic thematic blocks and release trajectory.
|
||||||
> This is the "big picture" — not individual specs.
|
> This is the "big picture" — not individual specs.
|
||||||
|
|
||||||
**Last updated**: 2026-04-22
|
**Last updated**: 2026-04-20
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -70,17 +70,6 @@ ### R1.9 Platform Localization v1 (DE/EN)
|
|||||||
|
|
||||||
## Planned (Next Quarter)
|
## Planned (Next Quarter)
|
||||||
|
|
||||||
### R2.0 Canonical Control Catalog Foundation
|
|
||||||
Framework-neutral canonical control core that bridges the shipped governance engine and later readiness or reporting overlays.
|
|
||||||
**Goal**: Give baselines, drift, findings, exceptions, evidence, and reports one shared control object before framework-specific mappings land.
|
|
||||||
|
|
||||||
- Framework-neutral canonical domains, subdomains, and control themes
|
|
||||||
- Detectability classes, evaluation strategies, evidence archetypes, and artifact suitability
|
|
||||||
- Microsoft subject and workload bindings for tenant-near technical controls
|
|
||||||
- Small seed catalog for v1 families such as strong authentication, conditional access, privileged access, endpoint baseline or hardening, sharing boundaries, mail protection, audit retention, and delegated admin boundaries
|
|
||||||
- Referenceable from Baseline Profiles, Compare and Drift, Findings, Exceptions, StoredReports, and EvidenceItems
|
|
||||||
- Foundation for later framework mappings, readiness views, and auditor packs
|
|
||||||
|
|
||||||
### R2 Completion — Evidence & Exception Workflows
|
### R2 Completion — Evidence & Exception Workflows
|
||||||
- Review pack export (Spec 109 — done)
|
- Review pack export (Spec 109 — done)
|
||||||
- Exception/risk-acceptance workflow for Findings → Spec 154 (draft)
|
- Exception/risk-acceptance workflow for Findings → Spec 154 (draft)
|
||||||
@ -144,23 +133,16 @@ ### PSA / Ticketing Handoff
|
|||||||
**Scope direction**: start with one-way handoff and internal visibility, not full bidirectional sync or full ITSM modeling.
|
**Scope direction**: start with one-way handoff and internal visibility, not full bidirectional sync or full ITSM modeling.
|
||||||
|
|
||||||
### Compliance Readiness & Executive Review Packs
|
### Compliance Readiness & Executive Review Packs
|
||||||
On-demand review packs that combine governance findings, accepted risks, evidence, baseline/drift posture, and key security signals into one coherent deliverable. CIS-aligned baseline libraries plus NIS2-/BSI-oriented readiness views (without certification claims). Executive / CISO / customer-facing report surfaces alongside operator-facing detail views. Exportable auditor-ready and management-ready outputs.
|
On-demand review packs that combine governance findings, accepted risks, evidence, baseline/drift posture, and key security signals into one coherent deliverable. BSI-/NIS2-/CIS-oriented readiness views (without certification claims). Executive / CISO / customer-facing report surfaces alongside operator-facing detail views. Exportable auditor-ready and management-ready outputs.
|
||||||
**Goal**: Make TenantPilot sellable as an MSP-facing governance and review platform for German midmarket and compliance-oriented customers who want structured tenant reviews and management-ready outputs on demand.
|
**Goal**: Make TenantPilot sellable as an MSP-facing governance and review platform for German midmarket and compliance-oriented customers who want structured tenant reviews and management-ready outputs on demand.
|
||||||
**Why it matters**: Turns existing governance data into a clear customer-facing value proposition. Strengthens MSP sales story beyond backup and restore. Creates a repeatable "review on demand" workflow for quarterly reviews, security health checks, and audit preparation.
|
**Why it matters**: Turns existing governance data into a clear customer-facing value proposition. Strengthens MSP sales story beyond backup and restore. Creates a repeatable "review on demand" workflow for quarterly reviews, security health checks, and audit preparation.
|
||||||
**Depends on**: Canonical Control Catalog Foundation, StoredReports / EvidenceItems foundation, Tenant Review runs, Findings + Risk Acceptance workflow, evidence / signal ingestion, export pipeline maturity.
|
**Depends on**: StoredReports / EvidenceItems foundation, Tenant Review runs, Findings + Risk Acceptance workflow, evidence / signal ingestion, export pipeline maturity.
|
||||||
**Scope direction**: Start as compliance readiness and review packaging. Avoid formal certification language or promises. Position as governance evidence, management reporting, and audit preparation.
|
**Scope direction**: Start as compliance readiness and review packaging. Avoid formal certification language or promises. Position as governance evidence, management reporting, and audit preparation.
|
||||||
**Modeling principle**: Compliance and governance requirements are modeled through a framework-neutral canonical control catalog plus technical interpretations and versioned framework overlays, not as separate technical object worlds per framework. Readiness views, evidence packs, baseline libraries, and auditor outputs are generated from that shared domain model.
|
**Modeling principle**: Compliance and governance requirements are modeled as versioned control catalogs, TenantPilot technical interpretations, evidence mappings, evaluation rules, manual attestations, and customer/MSP profiles, not as hardcoded framework-specific rules. Readiness views, evidence packs, and auditor outputs are generated from that shared domain model.
|
||||||
|
|
||||||
**Layering**:
|
- Separate framework source versions, TenantPilot interpretation versions, and customer/MSP profile versions
|
||||||
- **S1**: framework-neutral Canonical Control Catalog plus TenantPilot technical interpretations as the normative control core
|
- Map controls to evidence sources, evaluation rules, and manual attestations when automation is partial
|
||||||
- **S2**: CIS Baseline Library as a template and library layer built on top of the canonical catalog, not a separate control object model
|
- Keep BSI / NIS2 / CIS views as reporting layers on top of the shared control model
|
||||||
- **S3**: NIS2 and BSI readiness views as mapping and readiness layers built on the same canonical catalog and evidence model
|
|
||||||
- ISO and COBIT belong primarily in governance, assurance, ISMS, and readiness overlays on top of the shared catalog, not as separate technical subject or control worlds
|
|
||||||
|
|
||||||
- Separate canonical control catalog versions, technical interpretation versions, framework overlay versions, and customer/MSP profile versions
|
|
||||||
- Map canonical controls to evidence sources, evaluation rules, and manual attestations when automation is partial
|
|
||||||
- Keep CIS baseline templates and NIS2 / BSI readiness views as downstream layers on top of the shared canonical control model
|
|
||||||
- Keep ISO / COBIT semantics in governance-assurance and ISMS-oriented overlays rather than introducing a second technical control universe
|
|
||||||
- Avoid framework-specific one-off reports that bypass the common evidence, findings, exception, and export pipeline
|
- Avoid framework-specific one-off reports that bypass the common evidence, findings, exception, and export pipeline
|
||||||
|
|
||||||
### Entra Role Governance
|
### Entra Role Governance
|
||||||
|
|||||||
@ -5,7 +5,7 @@ # Spec Candidates
|
|||||||
>
|
>
|
||||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||||
|
|
||||||
**Last reviewed**: 2026-04-22 (promoted `Findings Notifications & Escalation v1` to Spec 224, promoted `Findings Notification Presentation Convergence` to Spec 230, added three architecture contract-enforcement candidates from the 2026-04-22 drift audit, added the repository cleanup strand from the strict read-only legacy audit, reframed the compliance-control foundation candidate into a framework-neutral canonical control catalog foundation, and aligned the control-library candidates to the S1/S2/S3 layering language)
|
**Last reviewed**: 2026-04-21 (added `My Work` candidate family and aligned it with existing promoted work)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -45,9 +45,6 @@ ## Promoted to Spec
|
|||||||
- Finding Ownership Semantics Clarification → Spec 219 (`finding-ownership-semantics`)
|
- Finding Ownership Semantics Clarification → Spec 219 (`finding-ownership-semantics`)
|
||||||
- Humanized Diagnostic Summaries for Governance Operations → Spec 220 (`governance-run-summaries`)
|
- Humanized Diagnostic Summaries for Governance Operations → Spec 220 (`governance-run-summaries`)
|
||||||
- Findings Operator Inbox v1 → Spec 221 (`findings-operator-inbox`)
|
- Findings Operator Inbox v1 → Spec 221 (`findings-operator-inbox`)
|
||||||
- Findings Intake & Team Queue v1 -> Spec 222 (`findings-intake-team-queue`)
|
|
||||||
- Findings Notifications & Escalation v1 → Spec 224 (`findings-notifications-escalation`)
|
|
||||||
- Findings Notification Presentation Convergence → Spec 230 (`findings-notification-convergence`)
|
|
||||||
- Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`)
|
- Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`)
|
||||||
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
|
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
|
||||||
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
|
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
|
||||||
@ -223,75 +220,6 @@ ### Operation Run Active-State Visibility & Stale Escalation
|
|||||||
- **Strategic sequencing**: Best treated before any broader operator intervention-actions spec. First make stale or hanging work visibly truthful across all existing surfaces; only then add retry / force-fail / reconcile-now UX on top of a coherent active-state language.
|
- **Strategic sequencing**: Best treated before any broader operator intervention-actions spec. First make stale or hanging work visibly truthful across all existing surfaces; only then add retry / force-fail / reconcile-now UX on top of a coherent active-state language.
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
> Architecture contract-enforcement cluster: these candidates come from the targeted repository drift audit on 2026-04-22. Most are intentionally narrower than naming, presentation, or IA cleanup work. The shared contracts already exist; the gap is that they are not yet treated as mandatory on every platform-owned path. The operation-type candidate is the deliberate exception because the audit found two active competing semantic contracts, not just a missing guardrail.
|
|
||||||
|
|
||||||
### Operation Run Link Contract Enforcement
|
|
||||||
- **Type**: hardening / contract enforcement
|
|
||||||
- **Source**: targeted repository architecture/pattern-drift audit 2026-04-22; canonical operation-link drift review
|
|
||||||
- **Problem**: TenantPilot already has a real canonical navigation contract in `OperationRunLinks` and `SystemOperationRunLinks`, but platform-owned UI and shared layers can still build `OperationRun` links through raw `route('admin.operations...')` calls. The same navigation class is therefore emitted through two parallel paths, including in shared navigation layers that otherwise already know the canonical link contract.
|
|
||||||
- **Why it matters**: This is not a missing-helper problem. It is a shared-contract bypass on a cross-cutting operator path. If it keeps spreading, tenant/workspace context, filter continuity, deep-link stability, and future operations IA changes all become more expensive because each surface can choose raw routes instead of the contract.
|
|
||||||
- **Proposed direction**:
|
|
||||||
- inventory platform-owned `OperationRun` collection/detail link producers and classify legitimate infrastructure exceptions
|
|
||||||
- move platform-owned UI and shared navigation layers to `OperationRunLinks` or `SystemOperationRunLinks`
|
|
||||||
- make collection/detail/context semantics part of the helper contract rather than repeated local route assembly
|
|
||||||
- add a lightweight guardrail that catches new raw `route('admin.operations...')` calls outside an explicit allowlist
|
|
||||||
- **Explicit non-goals**: Not a new operations IA, not a redesign of the operations pages, not a broad routing refactor for the whole repo, and not a change to `OperationRun` page content.
|
|
||||||
- **Boundary with Operation Run Active-State Visibility & Stale Escalation**: Active-state visibility owns lifecycle communication on compact and detail surfaces. This candidate owns canonical link generation and context continuity.
|
|
||||||
- **Boundary with Operator Presentation & Lifecycle Action Hardening**: Presentation hardening owns labels, badges, and action-visibility conventions. This candidate owns deep-link and collection-link contract enforcement.
|
|
||||||
- **Dependencies**: `OperationRunLinks`, `SystemOperationRunLinks`, canonical operations pages, and any repository signal or review guardrail infrastructure introduced by Spec 201.
|
|
||||||
- **Strategic sequencing**: First of this cluster. The leverage is high because the shared contract already exists and the surface area is concrete.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Canonical Operation Type Source of Truth
|
|
||||||
- **Type**: hardening / source-of-truth decision
|
|
||||||
- **Source**: strict read-only legacy / compatibility audit 2026-04-22; operation-type drift review
|
|
||||||
- **Problem**: The repo still carries two competing operation-type languages. `OperationCatalog` and several UI or read-model paths prefer canonical dotted names, while persistence, runtime writes, tests, registries, and supporting services still rely on historical underscore values. This is no longer just a thin alias shim; it is parallel semantic truth.
|
|
||||||
- **Why it matters**: This is now the strongest active compatibility debt in the repo. As long as underscore and dotted forms remain co-equal in specs, code, filters, registries, and tests, every new operation type or execution surface can reinforce drift instead of converging on one contract.
|
|
||||||
- **Goal**: Define one repo-wide normative operation-type language and make explicit which form is persisted, written, resolved at boundaries, and reflected in specs, resources, and tests.
|
|
||||||
- **In scope**:
|
|
||||||
- explicit decision between underscore and dotted operation-type language as repo-wide truth
|
|
||||||
- normative persistence, write, read, and resolution contract for operation types
|
|
||||||
- cleanup or narrowing of the current alias matrix in `OperationCatalog`
|
|
||||||
- convergence of `OperationCatalog`, `OperationRunType`, resources, supporting services, specs, and tests
|
|
||||||
- guardrails that prevent new dual-semantics operation types from being introduced without an explicit exit path
|
|
||||||
- **Out of scope**: cosmetic label-only renaming, generic repo-wide naming cleanup outside operation types, provider identity redesign, and Baseline Scope V2.
|
|
||||||
- **Key requirements**:
|
|
||||||
- exactly one normative operation-type language must exist
|
|
||||||
- the persisted and written truth must be explicit rather than inferred
|
|
||||||
- underscore and dotted forms must not remain permanent parallel truths
|
|
||||||
- any remaining compatibility boundary must be explicit, narrow, and exit-bounded
|
|
||||||
- specs, code, resources, and tests must converge on the same contract
|
|
||||||
- **Boundary with Operations Naming Harmonization Across Run Types, Catalog, UI, and Audit**: naming harmonization owns visible operator vocabulary and naming grammar. This candidate owns the underlying semantic and persistence contract for operation types.
|
|
||||||
- **Boundary with Operator Presentation & Lifecycle Action Hardening**: presentation hardening owns operator-facing labels and badges. This candidate owns the operation-type truth that capability, triage, provider, and related behavior decisions depend on.
|
|
||||||
- **Dependencies**: `OperationCatalog`, `OperationRunType`, capability/triage/provider decision points, operation resources and link helpers, and any repository guardrail infrastructure introduced by Spec 201.
|
|
||||||
- **Strategic sequencing**: Third step of the repository cleanup strand, after `Dead Transitional Residue Cleanup` and `Onboarding State Fallback Retirement`.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Platform Vocabulary Boundary Enforcement for Governed Subject Keys
|
|
||||||
- **Type**: hardening / platform-boundary clarification
|
|
||||||
- **Source**: targeted repository architecture/pattern-drift audit 2026-04-22; governed-subject vocabulary drift review
|
|
||||||
- **Problem**: The repo already treats `policy_type` as compatibility vocabulary rather than active platform language, yet platform-visible query keys, page state, filters, and read-model builders still expose `policy_type` alongside or instead of governed-subject terms. Legacy terminology therefore survives not just in storage or adapters, but in platform-visible boundaries.
|
|
||||||
- **Why it matters**: This undercuts the repo's own vocabulary migration contract. Contributors and operators continue to read an Intune-shaped key as active platform language even where the platform already has canonical governed-subject terms.
|
|
||||||
- **Proposed direction**:
|
|
||||||
- inventory platform-visible uses of `policy_type` and adjacent legacy keys in query/state/read-model boundaries
|
|
||||||
- distinguish allowed compatibility or storage boundaries from platform-visible vocabulary surfaces
|
|
||||||
- move platform-visible filter/state/query/read-model contracts to canonical governed-subject terminology
|
|
||||||
- preserve legacy input compatibility only through explicit normalizers or adapters
|
|
||||||
- add a guardrail that catches new platform-visible legacy key exposure outside allowed boundary zones
|
|
||||||
- **Explicit non-goals**: Not a full storage-column rename sweep, not a broad Intune debranding project, not a full governance-taxonomy redesign, and not a generic repo-wide terminology cleanup campaign.
|
|
||||||
- **Boundary with Spec 202 (Governance Subject Taxonomy)**: Spec 202 defines the taxonomy and canonical governed-subject vocabulary. This candidate enforces which keys are allowed to remain platform-visible at runtime boundaries.
|
|
||||||
- **Boundary with Spec 204 (Platform Core Vocabulary Hardening)**: If Spec 204 remains the active vocabulary-hardening vehicle, this candidate should be absorbed as the governed-subject boundary-enforcement slice rather than promoted as a second parallel vocabulary spec.
|
|
||||||
- **Dependencies**: Spec 202, `PlatformVocabularyGlossary`, `PlatformSubjectDescriptorNormalizer`, and the baseline/governance builder surfaces that currently expose platform-visible legacy keys.
|
|
||||||
- **Strategic sequencing**: Third of this cluster. It should follow the operations contract-enforcement work unless Spec 204 is revived sooner and absorbs it directly.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
> Recommended sequence for this cluster:
|
|
||||||
> 1. **Operation Run Link Contract Enforcement**
|
|
||||||
> 2. **Canonical Operation Type Source of Truth**
|
|
||||||
> 3. **Platform Vocabulary Boundary Enforcement for Governed Subject Keys**
|
|
||||||
>
|
|
||||||
> If Spec 204 is reactivated as the live vocabulary-hardening vehicle, candidate 3 should fold into that spec instead of creating a competing parallel effort.
|
|
||||||
|
|
||||||
### Baseline Snapshot Fidelity Semantics
|
### Baseline Snapshot Fidelity Semantics
|
||||||
- **Type**: hardening
|
- **Type**: hardening
|
||||||
- **Source**: semantic clarity & operator-language audit 2026-03-21
|
- **Source**: semantic clarity & operator-language audit 2026-03-21
|
||||||
@ -420,18 +348,42 @@ ### Tenant Operational Readiness & Status Truth Hierarchy
|
|||||||
- AC5: Operator can tell within seconds whether tenant is usable / usable with follow-up / limited / blocked / in need of action
|
- AC5: Operator can tell within seconds whether tenant is usable / usable with follow-up / limited / blocked / in need of action
|
||||||
- AC6: Recent successful operations reinforce confidence where appropriate but do not silently overwrite explicit verification truth
|
- AC6: Recent successful operations reinforce confidence where appropriate but do not silently overwrite explicit verification truth
|
||||||
- AC7: Primary tenant status communication suitable for MSP/enterprise use without requiring tribal knowledge to interpret contradictions
|
- AC7: Primary tenant status communication suitable for MSP/enterprise use without requiring tribal knowledge to interpret contradictions
|
||||||
- **Boundary with Dead Transitional Residue Cleanup**: That cleanup strand absorbs the earlier quick removal of the single most obvious legacy truth field (`Tenant.app_status`) plus adjacent dead-symbol residue. This candidate defines the broader truth hierarchy and presentation model that decides how all tenant status domains interrelate. The residue cleanup is a subset action that can proceed independently as a quick win; this candidate provides the architectural direction that prevents future truth leakage.
|
- **Boundary with Tenant App Status False-Truth Removal**: That candidate is a quick, bounded removal of the single most obvious legacy truth field (`Tenant.app_status`). This candidate defines the broader truth hierarchy and presentation model that decides how all tenant status domains interrelate. The false-truth removal is a subset action that can proceed independently as a quick win; this candidate provides the architectural direction that prevents future truth leakage.
|
||||||
- **Boundary with Provider Connection Status Vocabulary Cutover**: Provider vocabulary cutover owns the `ProviderConnection` model-level status field cleanup (canonical enum sources replacing legacy varchar fields). This candidate owns how provider truth appears on tenant-level surfaces in relation to other readiness domains. The vocabulary cutover provides cleaner provider truth; this candidate defines where and how that truth is presented alongside lifecycle, verification, RBAC, and evidence.
|
- **Boundary with Provider Connection Status Vocabulary Cutover**: Provider vocabulary cutover owns the `ProviderConnection` model-level status field cleanup (canonical enum sources replacing legacy varchar fields). This candidate owns how provider truth appears on tenant-level surfaces in relation to other readiness domains. The vocabulary cutover provides cleaner provider truth; this candidate defines where and how that truth is presented alongside lifecycle, verification, RBAC, and evidence.
|
||||||
- **Boundary with Inventory, Provider & Operability Semantics**: That candidate normalizes how inventory capture mode, provider health/prerequisites, and verification results are presented on their own surfaces. This candidate defines the tenant-level integration layer that synthesizes those domain truths into a single headline readiness answer. Complementary: domain-level semantic cleanup feeds into the readiness model.
|
- **Boundary with Inventory, Provider & Operability Semantics**: That candidate normalizes how inventory capture mode, provider health/prerequisites, and verification results are presented on their own surfaces. This candidate defines the tenant-level integration layer that synthesizes those domain truths into a single headline readiness answer. Complementary: domain-level semantic cleanup feeds into the readiness model.
|
||||||
- **Boundary with Spec 161 (Operator Explanation Layer)**: The explanation layer defines cross-cutting interpretation patterns for degraded/partial/suppressed results. This candidate defines the tenant-specific readiness truth hierarchy. The explanation layer may inform how degraded readiness states are explained, but the readiness model itself is tenant-domain-specific.
|
- **Boundary with Spec 161 (Operator Explanation Layer)**: The explanation layer defines cross-cutting interpretation patterns for degraded/partial/suppressed results. This candidate defines the tenant-specific readiness truth hierarchy. The explanation layer may inform how degraded readiness states are explained, but the readiness model itself is tenant-domain-specific.
|
||||||
- **Boundary with Spec 214 (Governance Operator Outcome Compression)**: Governance compression improves governance artifact scan surfaces. This candidate improves tenant operational readiness surfaces. Both reduce operator cognitive load but on different product domains.
|
- **Boundary with Spec 214 (Governance Operator Outcome Compression)**: Governance compression improves governance artifact scan surfaces. This candidate improves tenant operational readiness surfaces. Both reduce operator cognitive load but on different product domains.
|
||||||
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation (shared vocabulary foundation), Dead Transitional Residue Cleanup (quick win that removes the most obvious legacy truth plus adjacent dead residue — can proceed independently before or during this spec), Provider Connection Status Vocabulary Cutover (cleaner provider truth sources — soft dependency), existing tenant lifecycle semantics (Spec 143)
|
- **Dependencies**: Operator Outcome Taxonomy and Cross-Domain State Separation (shared vocabulary foundation), Tenant App Status False-Truth Removal (quick win that removes the most obvious legacy truth — can proceed independently before or during this spec), Provider Connection Status Vocabulary Cutover (cleaner provider truth sources — soft dependency), existing tenant lifecycle semantics (Spec 143)
|
||||||
- **Related specs / candidates**: Spec 143 (tenant lifecycle operability context semantics), Dead Transitional Residue Cleanup, Provider Connection Status Vocabulary Cutover, Provider Connection UX Clarity, Inventory Provider & Operability Semantics, Operator Outcome Taxonomy (Spec 156), Operation Run Active-State Visibility & Stale Escalation
|
- **Related specs / candidates**: Spec 143 (tenant lifecycle operability context semantics), Tenant App Status False-Truth Removal, Provider Connection Status Vocabulary Cutover, Provider Connection UX Clarity, Inventory Provider & Operability Semantics, Operator Outcome Taxonomy (Spec 156), Operation Run Active-State Visibility & Stale Escalation
|
||||||
- **Strategic sequencing**: Best tackled after the Operator Outcome Taxonomy provides shared vocabulary and after Dead Transitional Residue Cleanup removes the most obvious legacy truth and adjacent dead residue as a quick win. Should land before major portfolio, MSP rollup, or compliance readiness surfaces are built, since those surfaces will inherit the tenant readiness model as a foundational input.
|
- **Strategic sequencing**: Best tackled after the Operator Outcome Taxonomy provides shared vocabulary and after Tenant App Status False-Truth Removal removes the most obvious legacy truth as a quick win. Should land before major portfolio, MSP rollup, or compliance readiness surfaces are built, since those surfaces will inherit the tenant readiness model as a foundational input.
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
> Findings execution layer cluster: complementary to existing Spec 154 (`finding-risk-acceptance`). Keep these split so prioritization can pull workflow semantics, operator work surfaces, alerts, external handoff, and later portfolio operating slices independently instead of collapsing them into one oversized "Findings v2" spec.
|
> Findings execution layer cluster: complementary to existing Spec 154 (`finding-risk-acceptance`). Keep these split so prioritization can pull workflow semantics, operator work surfaces, alerts, external handoff, and later portfolio operating slices independently instead of collapsing them into one oversized "Findings v2" spec.
|
||||||
|
|
||||||
|
### Findings Intake & Team Queue v1
|
||||||
|
- **Type**: workflow execution / team operations
|
||||||
|
- **Source**: findings execution layer candidate pack 2026-04-17; missing intake surface before personal assignment
|
||||||
|
- **Problem**: A personal inbox does not solve how new or unassigned findings enter the workflow. Operators need an intake surface before work is personally assigned.
|
||||||
|
- **Why it matters**: Without intake, backlog triage stays hidden in general-purpose lists and unassigned work becomes easy to ignore or duplicate.
|
||||||
|
- **Proposed direction**: Introduce unassigned and needs-triage views, an optional claim action, and basic shared-worklist conventions; use filters or tabs that clearly separate intake from active execution; make the difference between unowned backlog and personally assigned work explicit.
|
||||||
|
- **Explicit non-goals**: Full team model, capacity planning, auto-routing, and load-balancing logic.
|
||||||
|
- **Dependencies**: Ownership semantics, findings filters/tabs, open-status definitions.
|
||||||
|
- **Roadmap fit**: Findings Workflow v2; prerequisite for a broader team operating model.
|
||||||
|
- **Strategic sequencing**: Third, after personal inbox foundations exist.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
|
### Findings Notifications & Escalation v1
|
||||||
|
- **Type**: alerts / workflow execution
|
||||||
|
- **Source**: findings execution layer candidate pack 2026-04-17; gap between assignment metadata and actionable control loop
|
||||||
|
- **Problem**: Assignment, reopen, due, and overdue states currently risk becoming silent metadata unless operators keep polling findings views.
|
||||||
|
- **Why it matters**: Due dates without reminders or escalation are visibility, not control. Existing alert foundations only create operator value if findings workflow emits actionable events.
|
||||||
|
- **Proposed direction**: Add notifications for assignment, system-driven reopen, due-soon, and overdue states; introduce minimal escalation to owner or a defined role; explicitly consume the existing alert and notification infrastructure rather than building a findings-specific delivery system.
|
||||||
|
- **Explicit non-goals**: Multi-stage escalation chains, a large notification-preference center, and bidirectional ticket synchronization.
|
||||||
|
- **Dependencies**: Ownership semantics, operator inbox/intake surfaces, due/SLA logic, alert plumbing.
|
||||||
|
- **Roadmap fit**: Findings workflow hardening on top of the existing alerting foundation.
|
||||||
|
- **Strategic sequencing**: After inbox and intake exist so notifications land on meaningful destinations.
|
||||||
|
- **Priority**: high
|
||||||
|
|
||||||
### Assignment Hygiene & Stale Work Detection
|
### Assignment Hygiene & Stale Work Detection
|
||||||
- **Type**: workflow hardening / operations hygiene
|
- **Type**: workflow hardening / operations hygiene
|
||||||
- **Source**: findings execution layer candidate pack 2026-04-17; assignment lifecycle hygiene gap analysis
|
- **Source**: findings execution layer candidate pack 2026-04-17; assignment lifecycle hygiene gap analysis
|
||||||
@ -489,105 +441,58 @@ ### Cross-Tenant Findings Workboard v1
|
|||||||
- **Roadmap fit**: MSP portfolio and operations.
|
- **Roadmap fit**: MSP portfolio and operations.
|
||||||
- **Priority**: medium-low
|
- **Priority**: medium-low
|
||||||
|
|
||||||
### Canonical Control Catalog Foundation
|
### Compliance Control Catalog & Interpretation Foundation
|
||||||
- **Type**: foundation
|
- **Type**: foundation
|
||||||
- **Source**: governance-engine gap analysis 2026-04-22, roadmap/principles alignment 2026-04-10, compliance modeling discussion, future framework-oriented readiness planning
|
- **Source**: roadmap/principles alignment 2026-04-10, compliance modeling discussion, future framework-oriented readiness planning
|
||||||
- **Vehicle**: new standalone candidate
|
- **Vehicle**: new standalone candidate
|
||||||
- **Layer position**: **S1** — normative control core
|
- **Problem**: TenantPilot now has an explicit roadmap direction toward BSI-/NIS2-/CIS-oriented readiness views and executive review packs, but it still lacks the bounded domain foundation that those outputs should consume. There is no explicit, versioned model for external framework sources, TenantPilot's technical interpretation of those controls, customer/MSP profile variants, or the mapping from controls to evidence, evaluation rules, and manual attestations. Without that foundation, framework support will predictably drift into hardcoded report logic, one-off service rules, and special-case exports. The same underlying governance evidence will be translated differently per feature, and changes to framework source versions, TenantPilot interpretation logic, or customer profile overrides will become impossible to track independently.
|
||||||
- **Problem**: TenantPilot already has a real governance engine across baseline profiles, baseline capture and compare, drift findings, findings workflow, exceptions, alerts, stored reports, evidence items, and tenant review packs, but it still lacks the shared canonical object those features should point at. Today the product risks modeling control meaning in three competing places: framework-specific overlays such as NIS2, BSI, ISO, or COBIT mappings; Microsoft service- or subject-specific lists such as Entra, Intune, Exchange, or Purview subjects; or feature-local assumptions embedded separately in baseline, drift, findings, evidence, and report logic. Without a framework-neutral canonical control catalog, the same technical control objective will be duplicated, evidence and control truth will blur together, and later readiness or reporting work will inherit inconsistent semantics.
|
- **Why it matters**: This is the difference between "framework-themed reports" and a sustainable compliance-readiness product. Enterprise and MSP buyers do not need TenantPilot to become a certification engine, but they do need repeatable, reviewable, version-aware mappings from governance evidence to framework-oriented control statements. A shared control-model foundation avoids three long-term failure modes: duplicated rule logic across multiple readiness/report features, inability to explain which product interpretation produced a given readiness result, and brittle customer-specific customizations that fork framework behavior instead of profiling it. If the product wants BSI/NIS2/CIS views later, it should first know which control source version, which TenantPilot interpretation version, and which customer profile produced each answer.
|
||||||
- **Why it matters**: This is the missing structural bridge between the current governance engine and later compliance-readiness overlays. Operators, customers, and auditors need one stable answer to "what control is this about?" before the platform can credibly say which Microsoft subjects support it, which evidence proves it, which findings violate it, or which frameworks map to it. A canonical control layer prevents framework duplication, keeps control, evidence, finding, exception, and report semantics aligned, and lets the product communicate detectability honestly instead of over-claiming technical verification.
|
|
||||||
- **Proposed direction**:
|
- **Proposed direction**:
|
||||||
- Introduce a framework-neutral canonical control catalog centered on control themes and objectives rather than framework clauses or raw Microsoft API objects
|
- Introduce a bounded compliance domain model with explicit concepts for framework registry, framework versions, control catalog entries, TenantPilot interpretation records, customer/MSP profiles, profile overrides, control-to-evidence mappings, evaluation rules, and manual attestations
|
||||||
- Define canonical domains and subdomains plus stable product-wide control keys that outlive individual APIs, workloads, or framework versions
|
- Separate three independent version layers: framework source version, TenantPilot interpretation version, and customer/MSP profile version
|
||||||
- Classify each control by control class, detectability class, evaluation strategy, evidence archetypes, and artifact suitability for baseline, drift, findings, exceptions, reports, and evidence packs
|
- Allow each control to declare automation posture such as fully automatable, partially automatable, manual-attestation-required, or outside-product-scope
|
||||||
- Add a Microsoft subject-binding layer that links one canonical control to supported subject families, workloads, and signals without collapsing the control model into service-specific schema mirrors
|
- Map one control to multiple evidence sources and evaluation rules, and allow one evidence source to support multiple controls
|
||||||
- Start with a deliberately small seed catalog of high-value tenant-near control families such as strong authentication, conditional access, privileged access exposure, guest or cross-tenant boundaries, endpoint compliance and hardening, sharing boundaries, mail protection, audit retention, data protection readiness, and delegated admin boundaries
|
- Treat risk exceptions, findings, stored reports, and readiness views as downstream consumers of the control model rather than the place where framework logic lives
|
||||||
- Make baseline profiles, compare and drift, findings, exceptions, stored reports, and evidence items able to reference a `canonical_control_key` or equivalent control-family contract instead of each feature inventing local control meaning
|
- Prefer pack/import-based control lifecycle management with preview, diff, activate, archive, and migration semantics over manual per-control CRUD as the primary maintenance path
|
||||||
- Keep framework mappings as a later overlay: prepare mapping structure now if useful, but do not make NIS2, BSI, ISO, COBIT, or similar frameworks the primary shape of the foundation
|
- Start with lightweight, product-owned control metadata and interpretation summaries rather than assuming full storage of normative framework text inside the product
|
||||||
- **Scope boundaries**:
|
- **Scope boundaries**:
|
||||||
- **In scope**: canonical control vocabulary, domain and subdomain taxonomy, stable canonical keys, detectability and evaluation classifications, evidence archetypes, Microsoft subject binding model, a small seed catalog for priority control families, and integration contracts for baseline, findings, exceptions, evidence, and reports
|
- **In scope**: framework registry/version model, control catalog foundation, interpretation/profile/override model, evidence and evaluation mapping model, manual attestation linkage, framework-pack import and diff lifecycle, bounded admin/registry surfaces where required to manage activation state and profile variants
|
||||||
- **Out of scope**: full framework catalogs, full NIS2, BSI, ISO, COBIT, or similar mappings, exhaustive Microsoft service coverage, giant control-library breadth, a full attestation engine, stakeholder-facing readiness or report UI, posture scoring models, or replacing the evidence domain with a second artifact store
|
- **Out of scope**: formal certification claims, legal/compliance advice, full framework-text publishing, comprehensive support for every control in every standard, broad stakeholder-facing reporting UI, one-off PDF generation, posture scoring models, or replacing the evidence domain with a second artifact store
|
||||||
- **Explicit non-goals**:
|
- **Explicit non-goals**:
|
||||||
- Not a certification engine or legal interpretation layer
|
- Not a certification engine or legal interpretation layer
|
||||||
- Not a framework-first registry where the same control is duplicated once per standard
|
- Not a hardcoded per-framework report generator
|
||||||
- Not a mirror of raw Microsoft API payload shapes as the product's control model
|
|
||||||
- Not a CIS-specific baseline library or template pack layer; that belongs above the catalog, not inside it
|
|
||||||
- Not a requirement to ingest every framework in full before the first useful control family ships
|
- Not a requirement to ingest every framework in full before the first useful control family ships
|
||||||
- Not a promise that every control becomes directly technically evaluable; indirect, attested, and external-evidence-only controls remain first-class
|
- Not a promise that every control becomes fully automatable; manual attestation remains a first-class path
|
||||||
- **Acceptance points**:
|
- **Acceptance points**:
|
||||||
- The platform can represent canonical domains, subdomains, and controls with stable keys independent of framework source versions
|
- The system can represent and distinguish a framework source version, a TenantPilot interpretation version, and a customer/MSP profile version for the same control family
|
||||||
- Every seed control declares control class, detectability class, evaluation strategy, and at least one evidence archetype
|
- A single control can map to multiple evidence requirements and evaluation rules, with optional manual attestation where automation is incomplete
|
||||||
- Every seed control can declare whether it is baseline-capable, drift-capable, finding-capable, exception-capable, and report or evidence-pack-capable
|
- The model can express whether a control is fully automatable, partially automatable, manual-only, or outside current product scope
|
||||||
- The model can bind one canonical control to multiple Microsoft subject families or signal sources without redefining the control per workload
|
- A framework-pack update can preview new, changed, and retired controls before activation
|
||||||
- Baselines, findings, evidence, exceptions, and later readiness or reporting work have a defined path to consume the canonical control layer instead of hardcoding local control semantics
|
- Framework-oriented readiness/reporting work can consume the shared model without introducing hardcoded BSI/NIS2/CIS rule paths in presentation features
|
||||||
- The foundation can explicitly represent controls that are direct-technical, indirect-technical, workflow-attested, or external-evidence-only without collapsing them into one false compliant/non-compliant path
|
- **Boundary with Evidence Domain Foundation**: Evidence Domain Foundation owns evidence capture, storage, completeness, and immutable artifacts. This candidate owns the normative control model, product interpretation layer, and mapping from controls to those evidence artifacts.
|
||||||
- **Boundary with Spec 202 (Governance Subject Taxonomy and Baseline Scope V2)**: Spec 202 defines governed-subject vocabulary and baseline-scope input contracts. This candidate defines the higher-order canonical control objects that can bind to those subject families and later unify baseline, findings, evidence, and reporting semantics above raw governed-subject lists.
|
- **Boundary with Spec 154 (Finding Risk Acceptance Lifecycle)**: Risk Acceptance owns the lifecycle for documented deviations once a control gap or finding exists. This candidate owns how controls are modeled, interpreted, and linked to evidence before any exception is approved.
|
||||||
- **Boundary with Evidence Domain Foundation**: Evidence Domain Foundation owns evidence capture, completeness, freshness, and immutable artifacts. This candidate owns the canonical control definitions those evidence artifacts can support and the detectability and evaluation semantics that explain what the evidence means.
|
- **Boundary with Compliance Readiness & Executive Review Packs**: Compliance Readiness owns stakeholder-facing views, review-pack composition, and report delivery. This candidate owns the shared framework/control layer those views should consume so readiness output does not hardcode framework semantics locally.
|
||||||
- **Boundary with Spec 154 (Finding Risk Acceptance Lifecycle)**: Risk Acceptance owns the lifecycle for approved deviations once a finding or control gap exists. This candidate owns the stable control object that exceptions and compensating-control semantics should refer to.
|
- **Dependencies**: Spec 153 (evidence-domain-foundation) as a soft dependency for the final evidence-mapping contract, findings and exception workflow direction, StoredReports / review-pack export maturity for downstream consumers
|
||||||
- **Boundary with CIS Baseline Library**: The CIS library owns reusable template packs and benchmark libraries built on top of canonical controls. This candidate owns the control ontology itself and must not absorb CIS-specific expected-state packs into the normative control core.
|
- **Related specs / candidates**: Spec 153 (evidence-domain-foundation), Spec 154 (finding-risk-acceptance), Spec 155 (tenant-review-layer), Compliance Readiness & Executive Review Packs, Security Posture Signals Foundation, Entra Role Governance, SharePoint Tenant-Level Sharing Governance
|
||||||
- **Boundary with Compliance Readiness & Executive Review Packs**: Compliance Readiness owns stakeholder-facing framework and readiness views plus report packaging. This candidate owns the framework-neutral control core those later views should map onto instead of inventing per-framework local logic.
|
- **Strategic sequencing**: Best tackled before any substantial BSI/NIS2/CIS-oriented readiness views or auditor-pack expansion, and after or in parallel with Evidence Domain Foundation hardens the evidence side of the contract. This is not required to finish current R1/R2 governance hardening, but it should land before framework-facing readiness output becomes a real product lane.
|
||||||
- **Dependencies**: Spec 202 (governed-subject vocabulary), Spec 153 (evidence-domain-foundation) for evidence-contract alignment, Spec 154 (finding-risk-acceptance), baseline and drift foundations, and downstream stored-report or review-pack consumers
|
- **Priority**: medium
|
||||||
- **Related specs / candidates**: Spec 153 (evidence-domain-foundation), Spec 154 (finding-risk-acceptance), Spec 155 (tenant-review-layer), Spec 202 (governance-subject-taxonomy), CIS Baseline Library, Compliance Readiness & Executive Review Packs, Security Posture Signals Foundation, Entra Role Governance, SharePoint Tenant-Level Sharing Governance
|
|
||||||
- **Strategic sequencing**: This is best treated as the bridge between the current governance engine and later framework-facing readiness work. It should land before substantial NIS2, BSI, ISO, COBIT, or similar mapping and auditor-pack expansion, and ideally before evidence or review surfaces hardcode control meaning locally.
|
|
||||||
- **Roadmap fit**: Early-R2 foundation layer between the shipped governance engine and later compliance-readiness overlays.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### CIS Baseline Library
|
|
||||||
- **Type**: feature / library layer
|
|
||||||
- **Source**: roadmap layering alignment 2026-04-22, baseline-library planning, future benchmark/template packaging
|
|
||||||
- **Vehicle**: new standalone candidate
|
|
||||||
- **Layer position**: **S2** — catalog-based template and library layer
|
|
||||||
- **Problem**: Once TenantPilot has a framework-neutral canonical control catalog, it still needs a reusable library layer for widely recognized baseline packs such as CIS without turning CIS into the product's primary control ontology. Today that distinction does not exist explicitly in the candidate stack. Without a separate library-layer candidate, CIS guidance will tend to leak downward into the canonical catalog or upward into readiness views, blurring three different concerns: what a control is, what a reusable benchmark template recommends, and how a framework-specific readiness statement should be derived.
|
|
||||||
- **Why it matters**: CIS is valuable to TenantPilot as a reusable template and benchmark library, not as the platform's canonical control object model. MSPs and operators need versioned, explainable baseline packs they can adopt, compare against, and use as a curated starting point. Keeping CIS in a library layer preserves the framework-neutral core, makes benchmark evolution manageable, and avoids letting one external source define the entire product architecture.
|
|
||||||
- **Proposed direction**:
|
|
||||||
- Introduce versioned CIS-aligned template packs and baseline libraries that map onto canonical controls rather than redefining them
|
|
||||||
- Keep library-pack lifecycle explicit: import or activate, preview, diff, archive, and supersede without mutating the underlying control ontology
|
|
||||||
- Let one library item express expected-state guidance, applicability, severity or importance hints, and subject-level realization on top of the canonical control catalog
|
|
||||||
- Allow baseline profiles and later compare or reporting features to reference CIS library packs as curated starters or benchmark templates rather than a second control taxonomy
|
|
||||||
- Preserve room for future non-CIS libraries such as company standards, MSP reference packs, or vertical-specific benchmark packs built on the same catalog
|
|
||||||
- **Scope boundaries**:
|
|
||||||
- **In scope**: CIS-aligned library-pack model, versioning and lifecycle, mapping to canonical controls and governed subjects, baseline-template consumption paths, and bounded operator-visible library metadata
|
|
||||||
- **Out of scope**: replacing the canonical control catalog, full framework readiness mapping, certification semantics, stakeholder-facing readiness reporting, or a generic pack marketplace
|
|
||||||
- **Explicit non-goals**:
|
|
||||||
- Not a second control ontology beside the canonical catalog
|
|
||||||
- Not a readiness or evidence-mapping layer for NIS2, BSI, ISO, or COBIT
|
|
||||||
- Not a requirement that every canonical control must have a CIS template entry
|
|
||||||
- Not a forced replacement of operator-defined baseline profiles; library packs remain reusable starting points and references
|
|
||||||
- **Acceptance points**:
|
|
||||||
- The platform can represent a CIS library version independently from canonical catalog versions and framework-readiness overlays
|
|
||||||
- A CIS library entry can point to canonical controls and governed-subject realizations without redefining the control itself
|
|
||||||
- Baseline workflows can consume CIS library packs as reusable templates or benchmark references without collapsing the product into a CIS-first model
|
|
||||||
- Library-pack evolution can show added, changed, retired, or superseded guidance without changing historical control meaning
|
|
||||||
- Future company-standard or MSP-specific libraries can reuse the same template-layer mechanics without inventing another control taxonomy
|
|
||||||
- **Boundary with Canonical Control Catalog Foundation**: The canonical catalog defines what the control is. The CIS library defines one reusable benchmark or template expression built on top of that control.
|
|
||||||
- **Boundary with Compliance Readiness & Executive Review Packs**: Compliance Readiness owns mapping, evidence assembly, and readiness statements for frameworks or stakeholder views. The CIS library owns reusable benchmark packs and templates, not readiness scoring or framework interpretation.
|
|
||||||
- **Dependencies**: Canonical Control Catalog Foundation, Spec 202 (governed-subject vocabulary), baseline and drift foundations, and evidence alignment where benchmark reporting later consumes library references
|
|
||||||
- **Related specs / candidates**: Canonical Control Catalog Foundation, Compliance Readiness & Executive Review Packs, Spec 202 (governance-subject-taxonomy), Spec 203 (baseline-compare-strategy), company standards / policy quality work
|
|
||||||
- **Strategic sequencing**: Conceptually this is the S2 layer between the canonical control core and later framework-readiness overlays. It can ship after the control foundation once the catalog and governed-subject bindings are stable enough to host reusable benchmark templates.
|
|
||||||
- **Roadmap fit**: S2 library layer for reusable benchmark and baseline packs.
|
|
||||||
- **Priority**: medium-high
|
|
||||||
|
|
||||||
### Compliance Readiness & Executive Review Packs
|
### Compliance Readiness & Executive Review Packs
|
||||||
- **Type**: feature
|
- **Type**: feature
|
||||||
- **Source**: roadmap-to-spec coverage audit 2026-03-18, R2 theme completion, product positioning for German midmarket / MSP governance
|
- **Source**: roadmap-to-spec coverage audit 2026-03-18, R2 theme completion, product positioning for German midmarket / MSP governance
|
||||||
- **Vehicle note**: Tenant review and publication-readiness semantics should extend existing Spec 155 (`tenant-review-layer`), not become a separate candidate. This candidate remains about broader management/stakeholder-facing readiness outputs beyond the current review-layer spec.
|
- **Vehicle note**: Tenant review and publication-readiness semantics should extend existing Spec 155 (`tenant-review-layer`), not become a separate candidate. This candidate remains about broader management/stakeholder-facing readiness outputs beyond the current review-layer spec.
|
||||||
- **Layer position**: **S3** — mapping, evidence, and readiness layer
|
|
||||||
- **Problem**: TenantPilot is building a strong evidence/data foundation (Spec 153, StoredReports, review pack export via Spec 109, findings, baselines), but there is no product-level capability that assembles this data into management-ready, customer-facing, or auditor-oriented readiness views. Enterprise customers, MSP account managers, and CISOs need structured governance outputs for recurring tenant reviews, audit preparation, and compliance conversations — not raw artifact collections or manual export assembly. The gap is not data availability; it is the absence of a dedicated readiness presentation and packaging layer that turns existing governance evidence into actionable, consumable deliverables.
|
- **Problem**: TenantPilot is building a strong evidence/data foundation (Spec 153, StoredReports, review pack export via Spec 109, findings, baselines), but there is no product-level capability that assembles this data into management-ready, customer-facing, or auditor-oriented readiness views. Enterprise customers, MSP account managers, and CISOs need structured governance outputs for recurring tenant reviews, audit preparation, and compliance conversations — not raw artifact collections or manual export assembly. The gap is not data availability; it is the absence of a dedicated readiness presentation and packaging layer that turns existing governance evidence into actionable, consumable deliverables.
|
||||||
- **Why it matters**: This is a core product differentiator and revenue-relevant capability for the MSP and German midmarket audience. Without it, TenantPilot remains an operator tool — powerful but invisible to the stakeholders who sign off on governance, approve budgets, and evaluate vendor value. Structured readiness outputs for NIS2, BSI, executive summaries, customer review packs, and later governance-assurance overlays make TenantPilot sellable as a governance review platform, not just a backup and configuration tool. This directly strengthens the MSP sales story for quarterly reviews, security health checks, and audit preparation.
|
- **Why it matters**: This is a core product differentiator and revenue-relevant capability for the MSP and German midmarket audience. Without it, TenantPilot remains an operator tool — powerful but invisible to the stakeholders who sign off on governance, approve budgets, and evaluate vendor value. Structured readiness outputs (lightweight BSI/NIS2/CIS-oriented views, executive summaries, customer review packs) make TenantPilot sellable as a governance review platform, not just a backup and configuration tool. This directly strengthens the MSP sales story for quarterly reviews, security health checks, and audit preparation.
|
||||||
- **Proposed direction**:
|
- **Proposed direction**:
|
||||||
- A dedicated readiness/review presentation layer that consumes evidence domain artifacts, findings summaries, baseline/drift posture, permission posture signals, and operational health data
|
- A dedicated readiness/review presentation layer that consumes evidence domain artifacts, findings summaries, baseline/drift posture, permission posture signals, and operational health data
|
||||||
- Management-ready output surfaces: executive summary views, customer-facing review dashboards, and structured readiness pages oriented toward frameworks such as BSI Grundschutz and NIS2 — in a lightweight, non-certification sense (governance evidence, not formal compliance claims)
|
- Management-ready output surfaces: executive summary views, customer-facing review dashboards, structured compliance readiness pages oriented toward frameworks such as BSI Grundschutz, NIS2, and CIS — in a lightweight, non-certification sense (governance evidence, not formal compliance claims)
|
||||||
- Exportable review packs that combine multiple evidence dimensions into a single coherent deliverable (PDF or structured export) for external stakeholders
|
- Exportable review packs that combine multiple evidence dimensions into a single coherent deliverable (PDF or structured export) for external stakeholders
|
||||||
- Tenant-scoped and workspace-scoped views — individual tenant reviews as well as portfolio-level readiness summaries
|
- Tenant-scoped and workspace-scoped views — individual tenant reviews as well as portfolio-level readiness summaries
|
||||||
- Clear separation from the Evidence Domain Foundation: evidence foundation owns curation, completeness tracking, and artifact storage; compliance readiness owns presentation, assembly, and stakeholder-facing output
|
- Clear separation from the Evidence Domain Foundation: evidence foundation owns curation, completeness tracking, and artifact storage; compliance readiness owns presentation, assembly, and stakeholder-facing output
|
||||||
- Keep ISO and COBIT in governance-, assurance-, ISMS-, and readiness-oriented overlays rather than introducing them as a separate technical control library
|
|
||||||
- Readiness views should be composable: an operator selects which dimensions to include in a review pack (e.g. baseline posture + findings summary + permission evidence + operational health) rather than a monolithic fixed report
|
- Readiness views should be composable: an operator selects which dimensions to include in a review pack (e.g. baseline posture + findings summary + permission evidence + operational health) rather than a monolithic fixed report
|
||||||
- **Explicit non-goals**: Not a formal certification engine — TenantPilot does not certify compliance or issue attestations. Not a legal or compliance advice system. Not a replacement for the Evidence Domain Foundation (which owns the data layer). Not a generic BI dashboard or data warehouse initiative. Not a PDF-only export task — the primary value is the structured readiness view, with export as a secondary delivery mechanism. Not a reimplementation of review pack export (Spec 109 handles CSV/ZIP). Not a customer-facing analytics suite.
|
- **Explicit non-goals**: Not a formal certification engine — TenantPilot does not certify compliance or issue attestations. Not a legal or compliance advice system. Not a replacement for the Evidence Domain Foundation (which owns the data layer). Not a generic BI dashboard or data warehouse initiative. Not a PDF-only export task — the primary value is the structured readiness view, with export as a secondary delivery mechanism. Not a reimplementation of review pack export (Spec 109 handles CSV/ZIP). Not a customer-facing analytics suite.
|
||||||
- **Boundary with Evidence Domain Foundation**: Evidence Domain Foundation = curation, completeness tracking, artifact storage, immutable snapshots. Compliance Readiness = presentation, assembly, framework-oriented views, stakeholder-facing outputs. Evidence Foundation is a prerequisite; Compliance Readiness is a consumer.
|
- **Boundary with Evidence Domain Foundation**: Evidence Domain Foundation = curation, completeness tracking, artifact storage, immutable snapshots. Compliance Readiness = presentation, assembly, framework-oriented views, stakeholder-facing outputs. Evidence Foundation is a prerequisite; Compliance Readiness is a consumer.
|
||||||
- **Boundary with Canonical Control Catalog Foundation**: Canonical Control Catalog Foundation = framework-neutral control core, detectability semantics, and control-to-subject or evidence alignment. Compliance Readiness = framework-aware presentation, rollup, and stakeholder-facing output built on top of that shared control layer.
|
- **Dependencies**: Evidence Domain Foundation (data layer), review pack export (Spec 109), findings workflow (Spec 111), baseline/drift engine (Specs 116–119), permission posture (Specs 104/105), audit log foundation (Spec 134)
|
||||||
- **Boundary with CIS Baseline Library**: The CIS library owns reusable template packs and benchmark baselines. Compliance Readiness owns NIS2, BSI, and later governance-assurance overlays that map evidence and control coverage into readiness statements.
|
|
||||||
- **Dependencies**: Canonical Control Catalog Foundation, Evidence Domain Foundation (data layer), review pack export (Spec 109), findings workflow (Spec 111), baseline/drift engine (Specs 116–119), permission posture (Specs 104/105), audit log foundation (Spec 134)
|
|
||||||
- **Strategic sequencing**: This is the S3 layer. It should consume the canonical control core and evidence model, and it should remain separate from the CIS template-library layer so benchmark packs and readiness mappings do not collapse into the same object family.
|
|
||||||
- **Priority**: medium (high strategic value, but depends on evidence foundation maturity)
|
- **Priority**: medium (high strategic value, but depends on evidence foundation maturity)
|
||||||
|
|
||||||
### Enterprise App / Service Principal Governance
|
### Enterprise App / Service Principal Governance
|
||||||
@ -1093,63 +998,32 @@ ### Provider Connection Legacy Cleanup
|
|||||||
- **Related specs**: Spec 081 (credential migration guardrails), Spec 088 (provider connection model), Spec 137 (data-layer provider prep)
|
- **Related specs**: Spec 081 (credential migration guardrails), Spec 088 (provider connection model), Spec 137 (data-layer provider prep)
|
||||||
- **Priority**: medium (deferred until normalization is complete)
|
- **Priority**: medium (deferred until normalization is complete)
|
||||||
|
|
||||||
> Repository cleanup strand from the strict read-only legacy audit 2026-04-22:
|
### Tenant App Status False-Truth Removal
|
||||||
> 1. **Dead Transitional Residue Cleanup**
|
- **Type**: hardening
|
||||||
> 2. **Onboarding State Fallback Retirement**
|
- **Source**: legacy / orphaned truth audit 2026-03-16
|
||||||
> 3. **Canonical Operation Type Source of Truth**
|
- **Classification**: quick removal
|
||||||
>
|
- **Problem**: `Tenant.app_status` is displayed in tenant UI as current operational truth even though production code no longer writes it. Operators can see a frozen "OK" or other stale badge that does not reflect the real provider connection state.
|
||||||
> The first two candidates remove dead or weakly justified compatibility residue. The third resolves the remaining core semantic conflict that still spans persistence, registries, resources, specs, and tests.
|
- **Why it matters**: This is misleading operator-facing truth, not just dead schema. It creates false confidence on a tier-1 admin surface.
|
||||||
|
- **Target model**: `Tenant`
|
||||||
### Dead Transitional Residue Cleanup
|
- **Canonical source of truth**: `ProviderConnection.consent_status` and `ProviderConnection.verification_status`
|
||||||
- **Type**: hardening / cleanup
|
- **Must stop being read**: `Tenant.app_status` in `TenantResource` table columns, infolist/details, filters, and badge-domain mapping.
|
||||||
- **Source**: strict read-only legacy / compatibility audit 2026-04-22; orphaned-truth residue review
|
- **Can be removed immediately**:
|
||||||
- **Absorbs / broadens**: the earlier `Tenant App Status False-Truth Removal` slice plus adjacent dead-symbol cleanup
|
- TenantResource reads of `app_status`
|
||||||
- **Problem**: The repo still contains smaller transitional residues that no longer carry active product semantics but still survive in code, badges, factories, fixtures, and tests. Confirmed examples include unused deprecated `BaselineProfile::STATUS_*` constants and orphaned tenant app-status residue that now mainly persists as badge, factory, fixture, and test conservat.
|
- tenant app-status badge domain / badge mapping usage
|
||||||
- **Why it matters**: Each residue is small, but together they blur the real domain language, preserve dead semantics in tests, and make later cleanup harder because it is no longer obvious which symbols are still authoritative.
|
- factory defaults that seed `app_status`
|
||||||
- **Goal**: Remove dead transitional residue that no longer drives runtime, UI, filter, cast, or API behavior, and clean up associated tests, fixtures, and factories in the same change.
|
- **Remove only after cutover**:
|
||||||
- **In scope**:
|
- the `tenants.app_status` column itself, once all UI/report/export reads are confirmed gone
|
||||||
- remove unused deprecated `BaselineProfile::STATUS_*` constants
|
- **Migration / backfill**: No backfill. One cleanup migration to drop `app_status`. `app_notes` may be dropped in the same migration only if it does not broaden the spec beyond tenant stale app fields.
|
||||||
- remove orphaned tenant app-status badge, factory, fixture, and test residue
|
- **UI / resource / policy / test impact**:
|
||||||
- verify that no hidden runtime, UI, filter, cast, or API dependency still exists before removal
|
- UI/resources: remove misleading badge and filter from tenant surfaces
|
||||||
- document the remaining active domain language after cleanup
|
- Policy: none
|
||||||
- **Out of scope**: operation-type dual semantics, onboarding state fallbacks, provider identity or migration review, Baseline Scope V2, and spec-backed legacy redirect paths.
|
- Tests: update `TenantFactory`, remove assertions that treat `app_status` as live truth
|
||||||
- **Key requirements**:
|
- **Scope boundaries**:
|
||||||
- dead deprecated constants must be removed when no productive reference remains
|
- In scope: remove stale tenant app-status reads and schema field
|
||||||
- orphaned badge, status, factory, and fixture residue must not survive as silent compatibility lore
|
- Out of scope: provider connection UX redesign, credential migration, broader tenant health redesign
|
||||||
- cleanup must include tests and fixtures in the same change
|
- **Dependencies**: None required if the immediate operator-facing action is removal rather than replacement with a new tenant-level derived badge.
|
||||||
- removal must prove there is no hidden runtime, UI, filter, cast, or API dependency
|
- **Risks**: Low rollout risk. Main risk is short-term operator confusion about where to view connection health after removal.
|
||||||
- the remaining canonical domain language must be clearer after cleanup
|
- **Why it should be its own spec**: This is the cleanest high-severity operator-trust fix in the repo. It is bounded, low-coupling, and should not wait for the larger provider cutover work.
|
||||||
- **Acceptance characteristics**:
|
|
||||||
- deprecated `BaselineProfile::STATUS_*` constants are gone
|
|
||||||
- tenant app-status residue is removed or reduced to explicitly justified boundary-only remnants
|
|
||||||
- no productive references to removed symbols remain
|
|
||||||
- tests no longer conserve dead semantics
|
|
||||||
- **Boundary with Provider Connection Legacy Cleanup**: provider connection cleanup owns still-legitimate or spec-bound provider transitional paths. This candidate only removes dead residue with no active product role.
|
|
||||||
- **Strategic sequencing**: first step of the repository cleanup strand.
|
|
||||||
- **Priority**: high
|
|
||||||
|
|
||||||
### Onboarding State Fallback Retirement
|
|
||||||
- **Type**: hardening / cleanup
|
|
||||||
- **Source**: strict read-only legacy / compatibility audit 2026-04-22; onboarding state-key audit
|
|
||||||
- **Problem**: Onboarding still carries mixed old and new state keys and service-level fallback reads between older fields and newer canonical fields. Some keys still have distinct roles, such as mutable selector state versus trusted persisted state, but others now appear to survive only as historical fallback.
|
|
||||||
- **Why it matters**: In a pre-production repo, silent fallback between state classes keeps semantic boundaries fuzzy and makes future trusted-state hardening harder. New work can accidentally bind to retired keys because the service layer still tolerates them.
|
|
||||||
- **Goal**: Retire pure onboarding fallback keys and make the remaining split between selector state and trusted persisted state explicit.
|
|
||||||
- **In scope**:
|
|
||||||
- audit and retire pure fallback keys such as `verification_run_id` and `bootstrap_run_ids` if no current contract still needs them
|
|
||||||
- remove corresponding fallback reads in onboarding services
|
|
||||||
- align contracts and tests to the remaining active key language
|
|
||||||
- document which onboarding keys remain active and why
|
|
||||||
- **Out of scope**: removing `selected_provider_connection_id` while it still has an active contract role, provider identity or migration review, and generic session or trusted-state architecture redesign.
|
|
||||||
- **Key requirements**:
|
|
||||||
- onboarding keys with no active contractual role must be removed when they survive only as fallback
|
|
||||||
- selector state and trusted state must be semantically separated
|
|
||||||
- silent fallback between semantically different state classes must not persist without an explicit current contract
|
|
||||||
- specs, contracts, and service read behavior must converge on the same remaining keys
|
|
||||||
- tests must stop conserving retired fallback fields
|
|
||||||
- **Risks / open questions**:
|
|
||||||
- `selected_provider_connection_id` still appears in current contracts and should not be treated as dead residue by default
|
|
||||||
- some onboarding keys may require contract cleanup before code cleanup can be completed cleanly
|
|
||||||
- **Strategic sequencing**: second step of the repository cleanup strand, after `Dead Transitional Residue Cleanup` and before `Canonical Operation Type Source of Truth`.
|
|
||||||
- **Priority**: high
|
- **Priority**: high
|
||||||
|
|
||||||
### Provider Connection Status Vocabulary Cutover
|
### Provider Connection Status Vocabulary Cutover
|
||||||
|
|||||||
199
specs/001-finding-ownership-semantics/plan.md
Normal file
199
specs/001-finding-ownership-semantics/plan.md
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
# Implementation Plan: Finding Ownership Semantics Clarification
|
||||||
|
|
||||||
|
**Branch**: `001-finding-ownership-semantics` | **Date**: 2026-04-20 | **Spec**: [spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/001-finding-ownership-semantics/spec.md`
|
||||||
|
|
||||||
|
**Note**: The setup script reported a numeric-prefix collision with `001-rbac-onboarding`, but it still resolved the active branch and plan path correctly to this feature directory. Planning continues against the current branch path.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Clarify the meaning of finding owner versus finding assignee across the existing tenant findings list, detail surface, responsibility-update flows, and exception-request context without adding new persistence, capabilities, or workflow services. The implementation will reuse the existing `owner_user_id` and `assignee_user_id` fields, add a derived responsibility-state presentation layer on top of current data, tighten operator-facing copy and audit/feedback wording, preserve tenant-safe Filament behavior, and extend focused Pest + Livewire coverage for list/detail semantics, responsibility updates, and exception-owner boundary cases.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.15 / Laravel 12
|
||||||
|
**Primary Dependencies**: Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4
|
||||||
|
**Storage**: PostgreSQL via Sail; existing `findings.owner_user_id`, `findings.assignee_user_id`, and `finding_exceptions.owner_user_id` fields; no schema changes planned
|
||||||
|
**Testing**: Pest v4 feature and Livewire component tests via `./vendor/bin/sail artisan test --compact`
|
||||||
|
**Validation Lanes**: fast-feedback, confidence
|
||||||
|
**Target Platform**: Laravel monolith in Sail/Docker locally; Dokploy-hosted Linux deployment for staging/production
|
||||||
|
**Project Type**: Laravel monolith / Filament admin application
|
||||||
|
**Performance Goals**: No new remote calls, no new queued work, no material list/detail query growth beyond current eager loading of owner, assignee, and exception-owner relations
|
||||||
|
**Constraints**: Keep responsibility state derived rather than persisted; preserve existing tenant membership validation; preserve deny-as-not-found tenant isolation; do not split capabilities or add new abstractions; keep destructive-action confirmations unchanged
|
||||||
|
**Scale/Scope**: 1 primary Filament resource, 1 model helper or equivalent derived-state mapping, 1 workflow/audit wording touchpoint, 1 exception-owner wording boundary, and 2 focused new/expanded feature test families
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Plan
|
||||||
|
|
||||||
|
- **Guardrail scope**: changed surfaces
|
||||||
|
- **Native vs custom classification summary**: native
|
||||||
|
- **Shared-family relevance**: existing tenant findings resource and exception-context surfaces only
|
||||||
|
- **State layers in scope**: page, detail, URL-query
|
||||||
|
- **Handling modes by drift class or surface**: review-mandatory
|
||||||
|
- **Repository-signal treatment**: review-mandatory
|
||||||
|
- **Special surface test profiles**: standard-native-filament
|
||||||
|
- **Required tests or manual smoke**: functional-core, state-contract
|
||||||
|
- **Exception path and spread control**: none; the feature reuses existing resource/table/infolist/action primitives and keeps exception-owner semantics local to the finding context
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- Inventory-first: PASS. The feature only clarifies operator semantics on tenant-owned findings and exception artifacts; it does not change observed-state or snapshot truth.
|
||||||
|
- Read/write separation: PASS. Responsibility updates remain TenantPilot-only writes on existing finding records; no Microsoft tenant mutation is introduced. Existing destructive-like actions keep their current confirmation rules.
|
||||||
|
- Graph contract path: PASS / N/A. No Graph calls or contract-registry changes are involved.
|
||||||
|
- Deterministic capabilities: PASS. Existing canonical findings capabilities remain the source of truth; no new capability split is introduced.
|
||||||
|
- RBAC-UX: PASS. Tenant-context membership remains the isolation boundary. Non-members remain 404 and in-scope members missing `TENANT_FINDINGS_ASSIGN` remain 403 for responsibility mutations.
|
||||||
|
- Workspace isolation: PASS. The feature remains inside tenant-context findings flows and preserves existing workspace-context stabilization patterns.
|
||||||
|
- Global search: PASS. `FindingResource` already has a `view` page, so any existing global-search participation remains compliant. This feature does not add or remove global search.
|
||||||
|
- Tenant isolation: PASS. All reads and writes remain tenant-scoped and continue to restrict owner/assignee selections to current tenant members.
|
||||||
|
- Run observability / Ops-UX: PASS / N/A. No new long-running, queued, or remote work is introduced and no `OperationRun` behavior changes.
|
||||||
|
- Automation / data minimization: PASS / N/A. No new background automation or payload persistence is introduced.
|
||||||
|
- Test governance (TEST-GOV-001): PASS. The narrowest proving surface is feature-level Filament + workflow coverage with low fixture cost and no heavy-family expansion.
|
||||||
|
- Proportionality / no premature abstraction / persisted truth / behavioral state: PASS. Responsibility state remains derived from existing fields and does not create a persisted enum, new abstraction, or new table.
|
||||||
|
- UI semantics / few layers: PASS. The plan uses direct domain-to-UI mapping on `FindingResource` and an optional local helper on `Finding` rather than a new presenter or taxonomy layer.
|
||||||
|
- Badge semantics (BADGE-001): PASS with restraint. If a new responsibility badge or label is added, it stays local to findings semantics unless multiple consumers later prove centralization is necessary.
|
||||||
|
- Filament-native UI / action surface contract / UX-001: PASS. Existing native Filament tables, infolists, filters, selects, grouped actions, and modals remain the implementation path; the finding remains the sole primary inspect/open model and row click remains the canonical inspect affordance.
|
||||||
|
|
||||||
|
**Post-Phase-1 re-check**: PASS. The design keeps responsibility semantics derived, tenant-safe, and local to the existing findings resource and tests.
|
||||||
|
|
||||||
|
## Test Governance Check
|
||||||
|
|
||||||
|
- **Test purpose / classification by changed surface**: Feature
|
||||||
|
- **Affected validation lanes**: fast-feedback, confidence
|
||||||
|
- **Why this lane mix is the narrowest sufficient proof**: The business truth is visible in Filament list/detail rendering, responsibility action behavior, and tenant-scoped audit feedback. Unit-only testing would miss the operator-facing semantics, while browser/heavy-governance coverage would add unnecessary cost.
|
||||||
|
- **Narrowest proving command(s)**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php`
|
||||||
|
- **Fixture / helper / factory / seed / context cost risks**: Low. Existing tenant/user/finding factories and tenant membership helpers are sufficient. The only explicit context risk is tenant-panel routing and admin canonical tenant state.
|
||||||
|
- **Expensive defaults or shared helper growth introduced?**: no; tests should keep tenant context explicit rather than broadening shared fixtures.
|
||||||
|
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||||
|
- **Surface-class relief / special coverage rule**: standard-native relief; explicit tenant-panel routing is required for authorization assertions.
|
||||||
|
- **Closing validation and reviewer handoff**: Re-run the two focused test files plus formatter on dirty files. Reviewers should verify owner versus assignee wording on list/detail surfaces, exception-owner separation, and 404/403 semantics for out-of-scope versus in-scope unauthorized users.
|
||||||
|
- **Budget / baseline / trend follow-up**: none
|
||||||
|
- **Review-stop questions**: lane fit, hidden fixture cost, accidental presenter growth, tenant-context drift in tests
|
||||||
|
- **Escalation path**: none
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Why no dedicated follow-up spec is needed**: This work stays inside the existing findings responsibility contract. Only future queue/team-routing or capability-split work would justify a separate spec.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/001-finding-ownership-semantics/
|
||||||
|
├── plan.md
|
||||||
|
├── spec.md
|
||||||
|
├── research.md
|
||||||
|
├── data-model.md
|
||||||
|
├── quickstart.md
|
||||||
|
├── contracts/
|
||||||
|
│ └── finding-responsibility.openapi.yaml
|
||||||
|
├── checklists/
|
||||||
|
│ └── requirements.md
|
||||||
|
└── tasks.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apps/platform/
|
||||||
|
├── app/
|
||||||
|
│ ├── Filament/
|
||||||
|
│ │ └── Resources/
|
||||||
|
│ │ └── FindingResource.php # MODIFY: list/detail labels, derived responsibility state, filters, action help text, exception-owner wording
|
||||||
|
│ ├── Models/
|
||||||
|
│ │ └── Finding.php # MODIFY: local derived responsibility-state helper if needed
|
||||||
|
│ └── Services/
|
||||||
|
│ └── Findings/
|
||||||
|
│ ├── FindingWorkflowService.php # MODIFY: mutation feedback / audit wording for owner-only vs assignee-only changes
|
||||||
|
│ ├── FindingExceptionService.php # MODIFY: request-exception wording if the exception-owner boundary needs alignment
|
||||||
|
│ └── FindingRiskGovernanceResolver.php # MODIFY: next-action copy to reflect orphaned-accountability semantics
|
||||||
|
└── tests/
|
||||||
|
└── Feature/
|
||||||
|
├── Filament/
|
||||||
|
│ └── Resources/
|
||||||
|
│ └── FindingResourceOwnershipSemanticsTest.php # NEW: list/detail rendering, filters, exception-owner distinction, tenant-safe semantics
|
||||||
|
└── Findings/
|
||||||
|
├── FindingAssignmentAuditSemanticsTest.php # NEW: owner-only, assignee-only, combined update feedback/audit semantics
|
||||||
|
├── FindingWorkflowRowActionsTest.php # MODIFY: assignment form/help-text semantics and member validation coverage
|
||||||
|
└── FindingWorkflowServiceTest.php # MODIFY: audit metadata and responsibility-mutation expectations
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Keep all work inside the existing Laravel/Filament monolith. The implementation is a targeted semantics pass over the current findings resource and workflow tests; no new folders, packages, or service families are required.
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| — | — | — |
|
||||||
|
|
||||||
|
## Proportionality Review
|
||||||
|
|
||||||
|
- **Current operator problem**: Operators cannot tell whether a finding change transferred accountability, active remediation work, or only exception ownership.
|
||||||
|
- **Existing structure is insufficient because**: The existing fields and actions exist, but the current UI copy and derived next-step language do not establish a stable contract across list, detail, and exception flows.
|
||||||
|
- **Narrowest correct implementation**: Reuse the existing `owner_user_id` and `assignee_user_id` fields, derive responsibility state from them, and tighten wording on existing Filament surfaces and audit feedback.
|
||||||
|
- **Ownership cost created**: Low ongoing UI/test maintenance to keep future findings work aligned with the clarified contract.
|
||||||
|
- **Alternative intentionally rejected**: A new ownership framework, queue model, or capability split was rejected because the current product has not yet exhausted the simpler owner-versus-assignee model.
|
||||||
|
- **Release truth**: Current-release truth
|
||||||
|
|
||||||
|
## Phase 0 — Research (output: `research.md`)
|
||||||
|
|
||||||
|
See: [research.md](./research.md)
|
||||||
|
|
||||||
|
Research goals:
|
||||||
|
- Confirm the existing source of truth for owner, assignee, and exception owner.
|
||||||
|
- Confirm the smallest derived responsibility-state model that fits the current schema.
|
||||||
|
- Confirm the existing findings tests and Filament routing pitfalls to avoid false negatives.
|
||||||
|
- Confirm which operator-facing wording changes belong in resource copy versus workflow service feedback.
|
||||||
|
|
||||||
|
## Phase 1 — Design & Contracts (outputs: `data-model.md`, `contracts/`, `quickstart.md`)
|
||||||
|
|
||||||
|
See:
|
||||||
|
- [data-model.md](./data-model.md)
|
||||||
|
- [contracts/finding-responsibility.openapi.yaml](./contracts/finding-responsibility.openapi.yaml)
|
||||||
|
- [quickstart.md](./quickstart.md)
|
||||||
|
|
||||||
|
Design focus:
|
||||||
|
- Keep responsibility truth on existing finding and finding-exception records.
|
||||||
|
- Model responsibility state as a derived projection over owner and assignee presence rather than a persisted enum.
|
||||||
|
- Preserve exception owner as a separate governance concept when shown from a finding context.
|
||||||
|
- Keep tenant membership validation and existing `FindingWorkflowService::assign()` semantics as the mutation boundary.
|
||||||
|
|
||||||
|
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
||||||
|
|
||||||
|
### Surface semantics pass
|
||||||
|
- Update the findings list column hierarchy so owner and assignee meaning is explicit at first scan.
|
||||||
|
- Add a derived responsibility-state label or equivalent summary on list/detail surfaces.
|
||||||
|
- Keep exception owner visibly separate from finding owner wherever both appear.
|
||||||
|
|
||||||
|
### Responsibility mutation clarity
|
||||||
|
- Add owner/assignee help text to assignment flows.
|
||||||
|
- Differentiate owner-only, assignee-only, and combined responsibility changes in operator feedback and audit-facing wording.
|
||||||
|
- Keep current tenant-member validation and open-finding restrictions unchanged.
|
||||||
|
|
||||||
|
### Personal-work and next-action alignment
|
||||||
|
- Add or refine personal-work filters so assignee-based work and owner-based accountability are explicitly separate.
|
||||||
|
- Update next-action copy for owner-missing states so assignee-only findings are treated as accountability gaps.
|
||||||
|
|
||||||
|
### Regression protection
|
||||||
|
- Add focused list/detail rendering tests for owner-only, assignee-only, both-set, same-user, and both-null states.
|
||||||
|
- Add focused responsibility-update tests for owner-only, assignee-only, and combined changes.
|
||||||
|
- Preserve tenant-context and authorization regression coverage using explicit Filament panel routing where needed.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- Run the two focused Pest files and any directly modified sibling findings tests.
|
||||||
|
- Run Pint on dirty files through Sail.
|
||||||
|
|
||||||
|
## Constitution Check (Post-Design)
|
||||||
|
|
||||||
|
Re-check result: PASS. The design stays inside the existing findings domain, preserves tenant isolation and capability enforcement, avoids new persisted truth or semantic framework growth, and keeps responsibility state derived from current fields.
|
||||||
|
|
||||||
|
## Filament v5 Agent Output Contract
|
||||||
|
|
||||||
|
1. **Livewire v4.0+ compliance**: Yes. The feature only adjusts existing Filament v5 resources/pages/actions that already run on Livewire v4.0+.
|
||||||
|
2. **Provider registration location**: No new panel or service providers are needed. Existing Filament providers remain registered in `apps/platform/bootstrap/providers.php`.
|
||||||
|
3. **Global search**: `FindingResource` already has a `view` page via `getPages()`, so any existing global-search participation remains compliant. This feature does not enable new global search.
|
||||||
|
4. **Destructive actions and authorization**: No new destructive actions are introduced. Existing destructive-like findings actions remain server-authorized and keep `->requiresConfirmation()` where already required. Responsibility updates continue to enforce tenant membership and the canonical findings capability registry.
|
||||||
|
5. **Asset strategy**: No new frontend assets or published views. The feature uses existing Filament tables, infolists, filters, and action modals, so deployment asset handling stays unchanged and no new `filament:assets` step is added.
|
||||||
|
6. **Testing plan**: Cover the change with focused Pest feature tests for findings resource responsibility semantics, assignment/audit wording, and existing workflow regression surfaces. No browser or heavy-governance expansion is planned.
|
||||||
204
specs/001-finding-ownership-semantics/spec.md
Normal file
204
specs/001-finding-ownership-semantics/spec.md
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
# Feature Specification: Finding Ownership Semantics Clarification
|
||||||
|
|
||||||
|
**Feature Branch**: `001-finding-ownership-semantics`
|
||||||
|
**Created**: 2026-04-20
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Finding Ownership Semantics Clarification"
|
||||||
|
|
||||||
|
## Spec Candidate Check *(mandatory — SPEC-GATE-001)*
|
||||||
|
|
||||||
|
- **Problem**: Open findings already store both an owner and an assignee, but the product does not yet state clearly which field represents accountability and which field represents active execution.
|
||||||
|
- **Today's failure**: Operators can see or change responsibility on a finding without being sure whether they transferred accountability, execution work, or only exception ownership, which makes triage slower and audit language less trustworthy.
|
||||||
|
- **User-visible improvement**: Findings surfaces make accountable owner, active assignee, and exception owner visibly distinct, so operators can route work faster and read ownership changes honestly.
|
||||||
|
- **Smallest enterprise-capable version**: Clarify the existing responsibility contract on current findings list/detail/action surfaces, add explicit derived responsibility states, and align audit and filter language without creating new roles, queues, or persistence.
|
||||||
|
- **Explicit non-goals**: No team queues, no escalation engine, no automatic reassignment hygiene, no new capability split, no new ownership framework, and no mandatory backfill before rollout.
|
||||||
|
- **Permanent complexity imported**: One explicit product contract for finding owner versus assignee, one derived responsibility-state vocabulary, and focused acceptance tests for mixed ownership contexts.
|
||||||
|
- **Why now**: The next findings execution slices depend on honest ownership semantics, and the ambiguity already affects triage, reassignment, and exception routing in today’s tenant workflow.
|
||||||
|
- **Why not local**: A one-surface copy fix would leave contradictory meaning across the findings list, detail view, assignment flows, personal work cues, and exception-request language.
|
||||||
|
- **Approval class**: Core Enterprise
|
||||||
|
- **Red flags triggered**: None after scope cut. This spec clarifies existing fields instead of introducing a new semantics axis.
|
||||||
|
- **Score**: Nutzen: 2 | Dringlichkeit: 2 | Scope: 2 | Komplexität: 2 | Produktnähe: 2 | Wiederverwendung: 1 | **Gesamt: 11/12**
|
||||||
|
- **Decision**: approve
|
||||||
|
|
||||||
|
## Spec Scope Fields *(mandatory)*
|
||||||
|
|
||||||
|
- **Scope**: tenant
|
||||||
|
- **Primary Routes**: `/admin/t/{tenant}/findings`, `/admin/t/{tenant}/findings/{finding}`
|
||||||
|
- **Data Ownership**: Tenant-owned findings remain the source of truth; this spec also covers exception ownership only when it is shown or edited from a finding-context surface.
|
||||||
|
- **RBAC**: Tenant membership is required for visibility. Tenant findings view permission gates read access. Tenant findings assign permission gates owner and assignee changes. Non-members or cross-tenant requests remain deny-as-not-found. Members without mutation permission receive an explicit authorization failure.
|
||||||
|
|
||||||
|
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||||
|
|
||||||
|
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| Tenant findings list and grouped bulk actions | yes | Native Filament + existing UI enforcement helpers | Same tenant work-queue family as other tenant resources | table, bulk-action modal, notification copy | no | Clarifies an existing resource instead of adding a new page |
|
||||||
|
| Finding detail and single-record action modals | yes | Native Filament infolist + action modals | Same finding resource family | detail, action modal, helper copy | no | Keeps one decision flow inside the current resource |
|
||||||
|
|
||||||
|
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant findings list and grouped bulk actions | Primary Decision Surface | A tenant operator reviews the open backlog and decides who owns the outcome and who should act next | Severity, lifecycle status, due state, owner, assignee, and derived responsibility state | Full evidence, audit trail, run metadata, and exception details | Primary because it is the queue where responsibility is routed at scale | Follows the operator’s “what needs action now?” workflow instead of raw data lineage | Removes the need to open each record just to tell who is accountable versus merely assigned |
|
||||||
|
| Finding detail and single-record action modals | Secondary Context Surface | A tenant operator verifies one finding before reassigning work or requesting an exception | Finding summary, current owner, current assignee, exception owner when applicable, and next workflow action | Raw evidence, historical context, and additional governance details | Secondary because it resolves one case after the list has already identified the work item | Preserves the single-record decision loop without creating a separate ownership page | Avoids cross-page reconstruction when ownership and exception context must be compared together |
|
||||||
|
|
||||||
|
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant findings list and grouped bulk actions | List / Table / Bulk | Workflow queue / list-first resource | Open a finding or update responsibility | Finding | required | Structured `More` action group and grouped bulk actions | Existing dangerous actions remain inside grouped actions with confirmation where already required | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant context, filters, severity, due state, workflow state | Findings / Finding | Accountable owner, active assignee, and whether the finding is assigned, owned-but-unassigned, or orphaned | none |
|
||||||
|
| Finding detail and single-record action modals | Record / Detail / Actions | View-first operational detail | Confirm or update responsibility for one finding | Finding | N/A - detail surface | Structured header actions on the existing record page | Existing dangerous actions remain grouped and confirmed | /admin/t/{tenant}/findings | /admin/t/{tenant}/findings/{finding} | Tenant breadcrumb, lifecycle state, due state, governance context | Findings / Finding | Finding owner, finding assignee, and exception owner stay visibly distinct in the same context | none |
|
||||||
|
|
||||||
|
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||||
|
|
||||||
|
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant findings list and grouped bulk actions | Tenant operator or tenant manager | Route responsibility on open findings and separate accountable ownership from active work | Workflow queue | Who owns this outcome, who is doing the work, and what needs reassignment now? | Severity, lifecycle state, due state, owner, assignee, derived responsibility state, and personal-work cues | Raw evidence, run identifiers, historical audit details | lifecycle, severity, governance validity, responsibility state | TenantPilot only | Open finding, Assign, Triage, Start progress | Resolve, Close, Request exception |
|
||||||
|
| Finding detail and single-record action modals | Tenant operator or tenant manager | Verify and change responsibility for one finding without losing exception context | Detail/action surface | Is this finding owned by the right person, assigned to the right executor, and clearly separate from any exception owner? | Finding summary, owner, assignee, exception owner when present, due state, lifecycle state | Raw evidence payloads, extended audit history, related run metadata | lifecycle, governance validity, responsibility state | TenantPilot only | Assign, Start progress, Resolve | Close, Request exception |
|
||||||
|
|
||||||
|
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||||
|
|
||||||
|
- **New source of truth?**: no
|
||||||
|
- **New persisted entity/table/artifact?**: no
|
||||||
|
- **New abstraction?**: no
|
||||||
|
- **New enum/state/reason family?**: no persisted family; only derived responsibility-state rules on existing fields
|
||||||
|
- **New cross-domain UI framework/taxonomy?**: no
|
||||||
|
- **Current operator problem**: Operators cannot reliably distinguish accountability from active execution, which creates avoidable misrouting and weak audit interpretation.
|
||||||
|
- **Existing structure is insufficient because**: Two existing user fields and an exception-owner concept can already appear in the same workflow, but current copy does not establish a stable product contract across list, detail, and action surfaces.
|
||||||
|
- **Narrowest correct implementation**: Reuse the existing finding owner and assignee fields, define their meaning, and apply that meaning consistently to labels, filters, derived states, and audit copy.
|
||||||
|
- **Ownership cost**: Focused UI copy review, acceptance tests for role combinations, and ongoing discipline to keep future findings work aligned with the same contract.
|
||||||
|
- **Alternative intentionally rejected**: A broader ownership framework with separate role types, queues, or team routing was rejected because it adds durable complexity before the product has proven the simpler owner-versus-assignee contract.
|
||||||
|
- **Release truth**: Current-release truth. This spec clarifies the meaning of responsibility on live findings now and becomes the contract for later execution surfaces.
|
||||||
|
|
||||||
|
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||||
|
|
||||||
|
- **Test purpose / classification**: Feature
|
||||||
|
- **Validation lane(s)**: fast-feedback, confidence
|
||||||
|
- **Why this classification and these lanes are sufficient**: The change is visible through tenant findings UI behavior, responsibility-state rendering, and audit-facing wording. Focused feature coverage proves the contract without introducing browser or heavy-governance breadth.
|
||||||
|
- **New or expanded test families**: Expand tenant findings resource coverage to include responsibility rendering, personal-work cues, and mixed-context exception-owner wording. Add focused assignment-audit coverage for owner-only, assignee-only, and combined changes.
|
||||||
|
- **Fixture / helper cost impact**: Low. Existing tenant, membership, user, and finding factories are sufficient; tests only need explicit responsibility combinations and tenant-scoped memberships.
|
||||||
|
- **Heavy-family visibility / justification**: none
|
||||||
|
- **Special surface test profile**: standard-native-filament
|
||||||
|
- **Standard-native relief or required special coverage**: Ordinary feature coverage is sufficient, with explicit assertions for owner versus assignee visibility and authorization behavior.
|
||||||
|
- **Reviewer handoff**: Reviewers should confirm that one positive and one negative authorization case exist, that list and detail surfaces use the same vocabulary, and that audit-facing feedback distinguishes owner changes from assignee changes.
|
||||||
|
- **Budget / baseline / trend impact**: none
|
||||||
|
- **Escalation needed**: none
|
||||||
|
- **Active feature PR close-out entry**: Guardrail
|
||||||
|
- **Planned validation commands**:
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/Resources/FindingResourceOwnershipSemanticsTest.php`
|
||||||
|
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingAssignmentAuditSemanticsTest.php`
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Route accountable ownership clearly (Priority: P1)
|
||||||
|
|
||||||
|
As a tenant operator, I want to tell immediately who owns a finding and who is actively assigned to it, so I can route remediation work without guessing which responsibility changed.
|
||||||
|
|
||||||
|
**Why this priority**: This is the smallest slice that fixes the core trust gap. Without it, every later execution surface inherits ambiguous meaning.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by loading the tenant findings list and detail pages with findings in owner-only, owner-plus-assignee, and no-owner states, and verifying that the first decision is possible without opening unrelated screens.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an open finding with an owner and no assignee, **When** an operator views the findings list, **Then** the record is shown as owned but unassigned rather than fully assigned.
|
||||||
|
2. **Given** an open finding with both an owner and an assignee, **When** an operator opens the finding detail, **Then** the accountable owner and active assignee are shown as separate roles.
|
||||||
|
3. **Given** an open finding with no owner, **When** an operator views the finding from the list or detail page, **Then** the accountability gap is surfaced as orphaned work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Reassign work without losing accountability (Priority: P2)
|
||||||
|
|
||||||
|
As a tenant manager, I want to change the assignee, the owner, or both in one responsibility update, so I can transfer execution work without silently transferring accountability.
|
||||||
|
|
||||||
|
**Why this priority**: Reassignment is the first mutation where the ambiguity causes operational mistakes and misleading audit history.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by performing responsibility updates on open findings and asserting separate outcomes for owner-only, assignee-only, and combined changes.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes only the assignee, **Then** the owner remains unchanged and the resulting feedback states that only the assignee changed.
|
||||||
|
2. **Given** an open finding with an existing owner and assignee, **When** an authorized operator changes only the owner, **Then** the assignee remains unchanged and the resulting feedback states that only the owner changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Keep exception ownership separate (Priority: P3)
|
||||||
|
|
||||||
|
As a governance reviewer, I want exception ownership to remain visibly separate from finding ownership when both appear in one flow, so risk-acceptance work does not overwrite the meaning of the finding owner.
|
||||||
|
|
||||||
|
**Why this priority**: This is a narrower but important boundary that prevents the accepted-risk workflow from reintroducing the same ambiguity under a second owner label.
|
||||||
|
|
||||||
|
**Independent Test**: Can be tested by opening a finding that has, or is about to create, an exception record and verifying that finding owner and exception owner remain distinct in the same operator context.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a finding detail view that shows both finding responsibility and exception context, **When** the operator reviews ownership information, **Then** exception ownership is labeled separately from finding owner.
|
||||||
|
2. **Given** an operator starts an exception request from a finding, **When** the request asks for exception ownership, **Then** the form language makes clear that the selected person owns the exception artifact, not the finding itself.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- A finding may have the same user as both owner and assignee; the UI must show both roles as satisfied without implying duplication or error.
|
||||||
|
- A finding with an assignee but no owner is still an orphaned accountability case, not a healthy assigned state.
|
||||||
|
- Historical or imported findings may have both responsibility fields empty; they must render as orphaned without blocking rollout on a data backfill.
|
||||||
|
- When personal-work cues are shown, assignee-based work and owner-based accountability must not collapse into one ambiguous shortcut.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature changes tenant-scoped finding workflow semantics only. It introduces no Microsoft Graph calls, no scheduled or long-running work, and no new `OperationRun`. Responsibility mutations remain audited tenant-local writes.
|
||||||
|
|
||||||
|
**Constitution alignment (RBAC-UX):** The affected authorization plane is tenant-context only. Cross-tenant and non-member access remain deny-as-not-found. Members without the findings assign permission continue to be blocked from responsibility mutations. The feature reuses the canonical capability registry and does not introduce raw role checks or a new capability split.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-FIL-001 / Filament Action Surfaces / UX-001):** The feature reuses existing native Filament tables, infolist entries, filters, selects, modal actions, grouped actions, and notifications. No local badge taxonomy or custom action chrome is added. The action-surface contract remains satisfied: the finding is still the one and only primary inspect/open model, row click remains the canonical inspect affordance on the list, no redundant view action is introduced, and existing dangerous actions remain grouped and confirmed where already required.
|
||||||
|
|
||||||
|
**Constitution alignment (UI-NAMING-001 / UI-SEM-001 / TEST-TRUTH-001):** Direct field labels alone are insufficient because finding owner, finding assignee, and exception owner can appear in the same operator flow. This feature resolves that ambiguity by tightening the product vocabulary on the existing domain truth instead of adding a new semantic layer or presenter family. Tests prove visible business consequences rather than thin indirection.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001**: The system MUST treat finding owner as the accountable user responsible for ensuring that a finding reaches a governed terminal outcome.
|
||||||
|
- **FR-002**: The system MUST treat finding assignee as the user currently expected to perform or coordinate active remediation work, and any operator-facing use of `assigned` language MUST refer to this role only.
|
||||||
|
- **FR-003**: Authorized users MUST be able to set, change, or clear finding owner and finding assignee independently on open findings while continuing to restrict selectable people to members of the active tenant.
|
||||||
|
- **FR-004**: The system MUST derive responsibility state from the existing owner and assignee fields without adding a persisted lifecycle field. At minimum it MUST distinguish `owned but unassigned`, `assigned`, and `orphaned accountability`.
|
||||||
|
- **FR-005**: Findings list, finding detail, and responsibility-update flows MUST label owner and assignee distinctly, and any mixed context that also references exception ownership MUST label that role as exception owner.
|
||||||
|
- **FR-006**: When personal-work shortcuts or filters are shown on the tenant findings list, the system MUST expose separate, explicitly named cues for assignee-based work and owner-based accountability.
|
||||||
|
- **FR-007**: Responsibility-update feedback and audit-facing wording MUST state whether a mutation changed the owner, the assignee, or both.
|
||||||
|
- **FR-008**: Historical or imported findings with null or partial responsibility data MUST render using the derived responsibility contract without requiring a blocking data migration before rollout.
|
||||||
|
- **FR-009**: Exception ownership MUST remain a separate governance concept. Creating, viewing, or editing an exception from a finding context MUST NOT silently replace the meaning of the finding owner.
|
||||||
|
|
||||||
|
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||||
|
|
||||||
|
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| Tenant findings list | `/admin/t/{tenant}/findings` | None added by this feature | Full-row open into finding detail | Primary open affordance plus `More` action group with `Triage`, `Start progress`, `Assign`, `Resolve`, `Close`, and `Request exception` | Grouped actions including `Triage selected`, `Assign selected`, `Resolve selected`, and existing close/exception-safe bulk actions if already present | Existing findings empty state remains; no new CTA introduced | N/A | N/A | Yes for responsibility mutations and existing workflow mutations | Action Surface Contract satisfied. No empty groups or redundant view action introduced. |
|
||||||
|
| Finding detail | `/admin/t/{tenant}/findings/{finding}` | Existing record-level workflow actions only | Entered from the list row click; no separate inspect action added | Structured record actions retain `Assign`, `Start progress`, `Resolve`, `Close`, and `Request exception` | N/A | N/A | Existing view header actions only | N/A | Yes for responsibility mutations and existing workflow mutations | UI-FIL-001 satisfied through existing infolist and action modal primitives. No exemption needed. |
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **Finding responsibility**: The visible responsibility contract attached to an open finding, consisting of accountable ownership, active execution assignment, and a derived responsibility state.
|
||||||
|
- **Finding owner**: The accountable person who owns the outcome of the finding and is expected to ensure it reaches a governed end state.
|
||||||
|
- **Finding assignee**: The person currently expected to perform or coordinate the remediation work on the finding.
|
||||||
|
- **Exception owner**: The accountable person for an exception artifact created from a finding; separate from finding owner whenever both appear in one operator context.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: In acceptance review, an authorized operator can identify owner, assignee, and responsibility state for any scripted open-finding example from the list or detail surface within 10 seconds.
|
||||||
|
- **SC-002**: 100% of covered responsibility combinations in automated acceptance tests, including same-person, owner-only, owner-plus-assignee, assignee-only, and both-empty cases, render the correct visible labels and derived state.
|
||||||
|
- **SC-003**: 100% of covered responsibility-update tests distinguish owner-only, assignee-only, and combined changes in operator feedback and audit-facing wording.
|
||||||
|
- **SC-004**: When personal-work shortcuts are present, an operator can isolate assignee-based work and owner-based accountability from the findings list in one interaction each.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- Existing finding owner and assignee fields remain the single source of truth for responsibility in this slice.
|
||||||
|
- Open findings may legitimately begin without an assignee while still needing an accountable owner.
|
||||||
|
- Membership hygiene for users who later lose tenant access is a separate follow-up concern and not solved by this clarification slice.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Introduce team, queue, or workgroup ownership.
|
||||||
|
- Add automatic escalation, reassignment, or inactivity timers.
|
||||||
|
- Split authorization into separate owner-edit and assignee-edit capabilities.
|
||||||
|
- Require a mandatory historical backfill before the clarified semantics can ship.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- Spec 111, Findings Workflow + SLA, remains the lifecycle and assignment baseline that this spec clarifies.
|
||||||
|
- Spec 154, Finding Risk Acceptance, remains the exception governance baseline whose owner semantics must stay separate from finding owner.
|
||||||
@ -159,10 +159,3 @@ ### Measurable Outcomes
|
|||||||
- **SC-004**: Every core page includes a visible next-step CTA and at least one deeper path into the product, trust, or contact story.
|
- **SC-004**: Every core page includes a visible next-step CTA and at least one deeper path into the product, trust, or contact story.
|
||||||
- **SC-005**: No released page contains placeholder copy, unsubstantiated trust or compliance claims, or speculative integration promises.
|
- **SC-005**: No released page contains placeholder copy, unsubstantiated trust or compliance claims, or speculative integration promises.
|
||||||
- **SC-006**: Core pages remain readable and navigable on both desktop and mobile widths without horizontal scrolling or hidden primary navigation.
|
- **SC-006**: Core pages remain readable and navigable on both desktop and mobile widths without horizontal scrolling or hidden primary navigation.
|
||||||
|
|
||||||
## Spec 223 Rebuild Status
|
|
||||||
|
|
||||||
- **Classification**: partially valid
|
|
||||||
- **Forward owner**: `../223-astrodeck-website-rebuild/mappings/spec-213-website-foundation-v0.md`
|
|
||||||
- **Material drift now recorded**: canonical trust routing now lives on `/trust`, `/security-trust` is redirect-only, `/changelog` and `/imprint` are canonical public surfaces, and `/legal`, `/terms`, `/solutions`, and `/integrations` are retained secondary routes under the smaller IA.
|
|
||||||
- **Forward-work rule**: new implementation work must start from the AstroDeck intake aliases and the Spec 223 mapping sheet instead of from the current `apps/website` implementation.
|
|
||||||
|
|||||||
@ -5,12 +5,6 @@ # Tasks: Initial Website Foundation & v0 Product Site
|
|||||||
|
|
||||||
**Tests**: Browser smoke coverage is required for this runtime-changing website feature, together with the root website build proof.
|
**Tests**: Browser smoke coverage is required for this runtime-changing website feature, together with the root website build proof.
|
||||||
|
|
||||||
## Historical Status
|
|
||||||
|
|
||||||
This implementation record is retained for traceability and is superseded by AstroDeck rebuild.
|
|
||||||
|
|
||||||
Forward planning now lives in `../223-astrodeck-website-rebuild/mappings/spec-213-website-foundation-v0.md`. Do not reset or reopen the checkbox state below; new work belongs to the Spec 223 mapping and follow-up slices.
|
|
||||||
|
|
||||||
## Test Governance Checklist
|
## Test Governance Checklist
|
||||||
|
|
||||||
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
- [X] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user