Compare commits
8 Commits
222-findin
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 421261a517 | |||
| 76334cb096 | |||
| 742d65f0d9 | |||
| 12fb5ebb30 | |||
| ccd4a17209 | |||
| 71f94c3afa | |||
| e15d80cca5 | |||
| 712576c447 |
53
.agents/skills/speckit-git-commit/SKILL.md
Normal file
53
.agents/skills/speckit-git-commit/SKILL.md
Normal file
@ -0,0 +1,53 @@
|
||||
---
|
||||
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
|
||||
72
.agents/skills/speckit-git-feature/SKILL.md
Normal file
72
.agents/skills/speckit-git-feature/SKILL.md
Normal file
@ -0,0 +1,72 @@
|
||||
---
|
||||
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
|
||||
54
.agents/skills/speckit-git-initialize/SKILL.md
Normal file
54
.agents/skills/speckit-git-initialize/SKILL.md
Normal file
@ -0,0 +1,54 @@
|
||||
---
|
||||
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
|
||||
50
.agents/skills/speckit-git-remote/SKILL.md
Normal file
50
.agents/skills/speckit-git-remote/SKILL.md
Normal file
@ -0,0 +1,50 @@
|
||||
---
|
||||
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
|
||||
54
.agents/skills/speckit-git-validate/SKILL.md
Normal file
54
.agents/skills/speckit-git-validate/SKILL.md
Normal file
@ -0,0 +1,54 @@
|
||||
---
|
||||
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
|
||||
50
.gemini/commands/speckit.git.commit.toml
Normal file
50
.gemini/commands/speckit.git.commit.toml
Normal file
@ -0,0 +1,50 @@
|
||||
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
|
||||
"""
|
||||
69
.gemini/commands/speckit.git.feature.toml
Normal file
69
.gemini/commands/speckit.git.feature.toml
Normal file
@ -0,0 +1,69 @@
|
||||
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
|
||||
"""
|
||||
51
.gemini/commands/speckit.git.initialize.toml
Normal file
51
.gemini/commands/speckit.git.initialize.toml
Normal file
@ -0,0 +1,51 @@
|
||||
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
|
||||
"""
|
||||
47
.gemini/commands/speckit.git.remote.toml
Normal file
47
.gemini/commands/speckit.git.remote.toml
Normal file
@ -0,0 +1,47 @@
|
||||
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
|
||||
"""
|
||||
51
.gemini/commands/speckit.git.validate.toml
Normal file
51
.gemini/commands/speckit.git.validate.toml
Normal file
@ -0,0 +1,51 @@
|
||||
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
@ -230,6 +230,16 @@ ## Active Technologies
|
||||
- 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, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger` (231-finding-outcome-taxonomy)
|
||||
- PostgreSQL via existing `findings`, `finding_exceptions`, `tenant_reviews`, `stored_reports`, and audit-log tables; no schema changes planned (231-finding-outcome-taxonomy)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -264,14 +274,9 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 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`
|
||||
- 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`
|
||||
- 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
|
||||
- 219-finding-ownership-semantics: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Pest v4, Tailwind CSS v4
|
||||
- 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
|
||||
- 231-finding-outcome-taxonomy: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + `App\Models\Finding`, `App\Filament\Resources\FindingResource`, `App\Services\Findings\FindingWorkflowService`, `App\Services\Baselines\BaselineAutoCloseService`, `App\Services\EntraAdminRoles\EntraAdminRolesFindingGenerator`, `App\Services\PermissionPosture\PermissionPostureFindingGenerator`, `App\Jobs\CompareBaselineToTenantJob`, `App\Filament\Pages\Reviews\ReviewRegister`, `App\Filament\Resources\TenantReviewResource`, `BadgeCatalog`, `BadgeRenderer`, `AuditLog` metadata via `AuditLogger`
|
||||
- 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`)
|
||||
- 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
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
### Pre-production compatibility check
|
||||
|
||||
51
.github/agents/speckit.git.commit.agent.md
vendored
Normal file
51
.github/agents/speckit.git.commit.agent.md
vendored
Normal file
@ -0,0 +1,51 @@
|
||||
---
|
||||
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
Normal file
70
.github/agents/speckit.git.feature.agent.md
vendored
Normal file
@ -0,0 +1,70 @@
|
||||
---
|
||||
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
Normal file
52
.github/agents/speckit.git.initialize.agent.md
vendored
Normal file
@ -0,0 +1,52 @@
|
||||
---
|
||||
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
Normal file
48
.github/agents/speckit.git.remote.agent.md
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
---
|
||||
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
Normal file
52
.github/agents/speckit.git.validate.agent.md
vendored
Normal file
@ -0,0 +1,52 @@
|
||||
---
|
||||
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,3 +673,8 @@ ### Replaced Utilities
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-clone | box-decoration-clone |
|
||||
</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
Normal file
3
.github/prompts/speckit.git.commit.prompt.md
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
---
|
||||
agent: speckit.git.commit
|
||||
---
|
||||
3
.github/prompts/speckit.git.feature.prompt.md
vendored
Normal file
3
.github/prompts/speckit.git.feature.prompt.md
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
---
|
||||
agent: speckit.git.feature
|
||||
---
|
||||
3
.github/prompts/speckit.git.initialize.prompt.md
vendored
Normal file
3
.github/prompts/speckit.git.initialize.prompt.md
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
---
|
||||
agent: speckit.git.initialize
|
||||
---
|
||||
3
.github/prompts/speckit.git.remote.prompt.md
vendored
Normal file
3
.github/prompts/speckit.git.remote.prompt.md
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
---
|
||||
agent: speckit.git.remote
|
||||
---
|
||||
3
.github/prompts/speckit.git.validate.prompt.md
vendored
Normal file
3
.github/prompts/speckit.git.validate.prompt.md
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
---
|
||||
agent: speckit.git.validate
|
||||
---
|
||||
295
.github/skills/browsertest/SKILL.md
vendored
Normal file
295
.github/skills/browsertest/SKILL.md
vendored
Normal file
@ -0,0 +1,295 @@
|
||||
---
|
||||
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.
|
||||
148
.specify/extensions.yml
Normal file
148
.specify/extensions.yml
Normal file
@ -0,0 +1,148 @@
|
||||
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
|
||||
44
.specify/extensions/.registry
Normal file
44
.specify/extensions/.registry
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
100
.specify/extensions/git/README.md
Normal file
100
.specify/extensions/git/README.md
Normal file
@ -0,0 +1,100 @@
|
||||
# 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)
|
||||
48
.specify/extensions/git/commands/speckit.git.commit.md
Normal file
48
.specify/extensions/git/commands/speckit.git.commit.md
Normal file
@ -0,0 +1,48 @@
|
||||
---
|
||||
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
|
||||
67
.specify/extensions/git/commands/speckit.git.feature.md
Normal file
67
.specify/extensions/git/commands/speckit.git.feature.md
Normal file
@ -0,0 +1,67 @@
|
||||
---
|
||||
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
|
||||
49
.specify/extensions/git/commands/speckit.git.initialize.md
Normal file
49
.specify/extensions/git/commands/speckit.git.initialize.md
Normal file
@ -0,0 +1,49 @@
|
||||
---
|
||||
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
|
||||
45
.specify/extensions/git/commands/speckit.git.remote.md
Normal file
45
.specify/extensions/git/commands/speckit.git.remote.md
Normal file
@ -0,0 +1,45 @@
|
||||
---
|
||||
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
|
||||
49
.specify/extensions/git/commands/speckit.git.validate.md
Normal file
49
.specify/extensions/git/commands/speckit.git.validate.md
Normal file
@ -0,0 +1,49 @@
|
||||
---
|
||||
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
|
||||
62
.specify/extensions/git/config-template.yml
Normal file
62
.specify/extensions/git/config-template.yml
Normal file
@ -0,0 +1,62 @@
|
||||
# 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"
|
||||
140
.specify/extensions/git/extension.yml
Normal file
140
.specify/extensions/git/extension.yml
Normal file
@ -0,0 +1,140 @@
|
||||
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"
|
||||
62
.specify/extensions/git/git-config.yml
Normal file
62
.specify/extensions/git/git-config.yml
Normal file
@ -0,0 +1,62 @@
|
||||
# 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"
|
||||
140
.specify/extensions/git/scripts/bash/auto-commit.sh
Executable file
140
.specify/extensions/git/scripts/bash/auto-commit.sh
Executable file
@ -0,0 +1,140 @@
|
||||
#!/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
|
||||
453
.specify/extensions/git/scripts/bash/create-new-feature.sh
Executable file
453
.specify/extensions/git/scripts/bash/create-new-feature.sh
Executable file
@ -0,0 +1,453 @@
|
||||
#!/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
|
||||
54
.specify/extensions/git/scripts/bash/git-common.sh
Executable file
54
.specify/extensions/git/scripts/bash/git-common.sh
Executable file
@ -0,0 +1,54 @@
|
||||
#!/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
|
||||
}
|
||||
54
.specify/extensions/git/scripts/bash/initialize-repo.sh
Executable file
54
.specify/extensions/git/scripts/bash/initialize-repo.sh
Executable file
@ -0,0 +1,54 @@
|
||||
#!/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
|
||||
169
.specify/extensions/git/scripts/powershell/auto-commit.ps1
Normal file
169
.specify/extensions/git/scripts/powershell/auto-commit.ps1
Normal file
@ -0,0 +1,169 @@
|
||||
#!/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"
|
||||
@ -0,0 +1,403 @@
|
||||
#!/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"
|
||||
}
|
||||
}
|
||||
51
.specify/extensions/git/scripts/powershell/git-common.ps1
Normal file
51
.specify/extensions/git/scripts/powershell/git-common.ps1
Normal file
@ -0,0 +1,51 @@
|
||||
#!/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
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
#!/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"
|
||||
10
.specify/init-options.json
Normal file
10
.specify/init-options.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"ai": "copilot",
|
||||
"branch_numbering": "sequential",
|
||||
"context_file": ".github/copilot-instructions.md",
|
||||
"here": true,
|
||||
"integration": "copilot",
|
||||
"preset": null,
|
||||
"script": "sh",
|
||||
"speckit_version": "0.7.4"
|
||||
}
|
||||
4
.specify/integration.json
Normal file
4
.specify/integration.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"integration": "copilot",
|
||||
"version": "0.7.4"
|
||||
}
|
||||
25
.specify/integrations/copilot.manifest.json
Normal file
25
.specify/integrations/copilot.manifest.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
8
.specify/integrations/speckit.manifest.json
Normal file
8
.specify/integrations/speckit.manifest.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"integration": "speckit",
|
||||
"version": "0.7.4",
|
||||
"installed_at": "2026-04-22T21:58:02.965809+00:00",
|
||||
"files": {
|
||||
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3"
|
||||
}
|
||||
}
|
||||
@ -1,17 +1,30 @@
|
||||
<!--
|
||||
Sync Impact Report
|
||||
|
||||
- Version change: 2.6.0 -> 2.7.0
|
||||
- Version change: 2.7.0 -> 2.8.0
|
||||
- Modified principles: None
|
||||
- Added sections:
|
||||
- Pre-Production Lean Doctrine (LEAN-001): forbids legacy aliases,
|
||||
migration shims, dual-write logic, and compatibility fixtures in a
|
||||
pre-production codebase; includes AI-agent verification checklist,
|
||||
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
|
||||
- Templates requiring updates:
|
||||
- .specify/templates/spec-template.md: added "Compatibility posture"
|
||||
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
|
||||
compatibility check" agent checklist ✅
|
||||
- Commands checked:
|
||||
@ -70,6 +83,14 @@ ### 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.
|
||||
- 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)
|
||||
- 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.
|
||||
|
||||
@ -26,18 +26,24 @@ ## 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.
|
||||
- [ ] 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
|
||||
|
||||
- [ ] CHK007 Any triggered repository signal is classified with one handling mode: `hard-stop-candidate`, `review-mandatory`, `exception-required`, or `report-only`.
|
||||
- [ ] 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.
|
||||
- [ ] CHK009 The required surface test profile is explicit: `shared-detail-family`, `monitoring-state-page`, `global-context-shell`, `exception-coded-surface`, or `standard-native-filament`.
|
||||
- [ ] 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.
|
||||
- [ ] CHK010 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.
|
||||
- [ ] CHK012 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.
|
||||
|
||||
## Review Outcome
|
||||
|
||||
- [ ] CHK011 One review outcome class is chosen: `blocker`, `strong-warning`, `documentation-required-exception`, or `acceptable-special-case`.
|
||||
- [ ] CHK012 One workflow outcome is chosen: `keep`, `split`, `document-in-feature`, `follow-up-spec`, or `reject-or-split`.
|
||||
- [ ] 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.
|
||||
- [ ] CHK014 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`.
|
||||
- [ ] 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.
|
||||
|
||||
## Notes
|
||||
|
||||
@ -48,7 +54,7 @@ ## Notes
|
||||
- `keep`: the current scope, guardrail handling, and proof depth are justified.
|
||||
- `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.
|
||||
- `follow-up-spec`: the issue is recurring or structural and needs dedicated governance follow-up.
|
||||
- `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.
|
||||
- `reject-or-split`: hidden drift, unresolved exception spread, or wrong proof depth blocks merge as proposed.
|
||||
- Check items off as completed: `[x]`
|
||||
- Add comments or findings inline
|
||||
|
||||
50
.specify/templates/constitution-template.md
Normal file
50
.specify/templates/constitution-template.md
Normal file
@ -0,0 +1,50 @@
|
||||
# [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,6 +43,17 @@ ## UI / Surface Guardrail Plan
|
||||
- **Exception path and spread control**: [none / describe the named exception boundary]
|
||||
- **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
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
@ -70,6 +81,7 @@ ## Constitution Check
|
||||
- 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
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@ -35,6 +35,18 @@ ## Spec Scope Fields *(mandatory)*
|
||||
- **Default filter behavior when tenant-context is active**: [e.g., prefilter to current tenant]
|
||||
- **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`)*
|
||||
|
||||
Use this section to classify UI and surface risk once. If the feature does
|
||||
@ -214,6 +226,14 @@ ## Requirements *(mandatory)*
|
||||
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.
|
||||
|
||||
**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:
|
||||
- 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,
|
||||
|
||||
@ -46,6 +46,11 @@ # Tasks: [FEATURE NAME]
|
||||
- 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,
|
||||
- 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:
|
||||
- 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`),
|
||||
|
||||
63
.specify/workflows/speckit/workflow.yml
Normal file
63
.specify/workflows/speckit/workflow.yml
Normal file
@ -0,0 +1,63 @@
|
||||
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 }}"
|
||||
13
.specify/workflows/workflow-registry.json
Normal file
13
.specify/workflows/workflow-registry.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,659 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\Filament\CanonicalAdminTenantFilterState;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
@ -352,7 +353,14 @@ private function reviewOutcomeBadgeIconColor(TenantReview $record): ?string
|
||||
|
||||
private function reviewOutcomeDescription(TenantReview $record): ?string
|
||||
{
|
||||
return $this->reviewOutcome($record)->primaryReason;
|
||||
$primaryReason = $this->reviewOutcome($record)->primaryReason;
|
||||
$findingOutcomeSummary = $this->findingOutcomeSummary($record);
|
||||
|
||||
if ($findingOutcomeSummary === null) {
|
||||
return $primaryReason;
|
||||
}
|
||||
|
||||
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.');
|
||||
}
|
||||
|
||||
private function reviewOutcomeNextStep(TenantReview $record): string
|
||||
@ -373,4 +381,16 @@ private function reviewOutcome(TenantReview $record, bool $fresh = false): Compr
|
||||
SurfaceCompressionContext::reviewRegister(),
|
||||
);
|
||||
}
|
||||
|
||||
private function findingOutcomeSummary(TenantReview $record): ?string
|
||||
{
|
||||
$summary = is_array($record->summary) ? $record->summary : [];
|
||||
$outcomeCounts = $summary['finding_outcomes'] ?? [];
|
||||
|
||||
if (! is_array($outcomeCounts)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
|
||||
}
|
||||
}
|
||||
|
||||
@ -234,7 +234,8 @@ public static function table(Table $table): Table
|
||||
->searchable(),
|
||||
TextColumn::make('event_type')
|
||||
->label('Event')
|
||||
->badge(),
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => AlertRuleResource::eventTypeLabel((string) $state)),
|
||||
TextColumn::make('severity')
|
||||
->badge()
|
||||
->formatStateUsing(fn (?string $state): string => ucfirst((string) $state))
|
||||
|
||||
@ -380,6 +380,10 @@ public static function eventTypeOptions(): array
|
||||
AlertRule::EVENT_SLA_DUE => 'SLA due',
|
||||
AlertRule::EVENT_PERMISSION_MISSING => 'Permission missing',
|
||||
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',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Filament\FilterPresets;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\Filament\TablePaginationProfiles;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||
@ -156,6 +157,14 @@ public static function infolist(Schema $schema): Schema
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus)),
|
||||
TextEntry::make('finding_terminal_outcome')
|
||||
->label('Terminal outcome')
|
||||
->state(fn (Finding $record): ?string => static::terminalOutcomeLabel($record))
|
||||
->visible(fn (Finding $record): bool => static::terminalOutcomeLabel($record) !== null),
|
||||
TextEntry::make('finding_verification_state')
|
||||
->label('Verification')
|
||||
->state(fn (Finding $record): ?string => static::verificationStateLabel($record))
|
||||
->visible(fn (Finding $record): bool => static::verificationStateLabel($record) !== null),
|
||||
TextEntry::make('severity')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::FindingSeverity))
|
||||
@ -292,9 +301,15 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('in_progress_at')->label('In progress at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('reopened_at')->label('Reopened at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('resolved_at')->label('Resolved at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('resolved_reason')->label('Resolved reason')->placeholder('—'),
|
||||
TextEntry::make('resolved_reason')
|
||||
->label('Resolved reason')
|
||||
->formatStateUsing(fn (?string $state): string => static::resolveReasonLabel($state) ?? '—')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('closed_at')->label('Closed at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('closed_reason')->label('Closed/risk reason')->placeholder('—'),
|
||||
TextEntry::make('closed_reason')
|
||||
->label('Closed/risk reason')
|
||||
->formatStateUsing(fn (?string $state): string => static::closeReasonLabel($state) ?? '—')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('closed_by_user_id')
|
||||
->label('Closed by')
|
||||
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->closedByUser?->name ?? ($state ? 'User #'.$state : '—')),
|
||||
@ -726,7 +741,7 @@ public static function table(Table $table): Table
|
||||
->color(BadgeRenderer::color(BadgeDomain::FindingStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::FindingStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::FindingStatus))
|
||||
->description(fn (Finding $record): string => static::primaryNarrative($record)),
|
||||
->description(fn (Finding $record): string => static::statusDescription($record)),
|
||||
Tables\Columns\TextColumn::make('governance_validity')
|
||||
->label('Governance')
|
||||
->badge()
|
||||
@ -820,6 +835,14 @@ public static function table(Table $table): Table
|
||||
Tables\Filters\SelectFilter::make('status')
|
||||
->options(FilterOptionCatalog::findingStatuses())
|
||||
->label('Status'),
|
||||
Tables\Filters\SelectFilter::make('terminal_outcome')
|
||||
->label('Terminal outcome')
|
||||
->options(FilterOptionCatalog::findingTerminalOutcomes())
|
||||
->query(fn (Builder $query, array $data): Builder => static::applyTerminalOutcomeFilter($query, $data['value'] ?? null)),
|
||||
Tables\Filters\SelectFilter::make('verification_state')
|
||||
->label('Verification')
|
||||
->options(FilterOptionCatalog::findingVerificationStates())
|
||||
->query(fn (Builder $query, array $data): Builder => static::applyVerificationStateFilter($query, $data['value'] ?? null)),
|
||||
Tables\Filters\SelectFilter::make('workflow_family')
|
||||
->label('Workflow family')
|
||||
->options(FilterOptionCatalog::findingWorkflowFamilies())
|
||||
@ -1092,16 +1115,20 @@ public static function table(Table $table): Table
|
||||
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('resolve_selected')
|
||||
->label('Resolve selected')
|
||||
->label(GovernanceActionCatalog::rule('resolve_finding')->canonicalLabel.' selected')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(GovernanceActionCatalog::rule('resolve_finding')->modalHeading)
|
||||
->modalDescription(GovernanceActionCatalog::rule('resolve_finding')->modalDescription)
|
||||
->form([
|
||||
Textarea::make('resolved_reason')
|
||||
->label('Resolution reason')
|
||||
->rows(3)
|
||||
Select::make('resolved_reason')
|
||||
->label('Resolution outcome')
|
||||
->options(static::resolveReasonOptions())
|
||||
->helperText('Use the canonical manual remediation outcome. Trusted verification is recorded later by system evidence.')
|
||||
->native(false)
|
||||
->required()
|
||||
->maxLength(255),
|
||||
->selectablePlaceholder(false),
|
||||
])
|
||||
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -1145,7 +1172,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
}
|
||||
|
||||
$body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's').'.';
|
||||
$body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's')." pending verification.";
|
||||
if ($skippedCount > 0) {
|
||||
$body .= " Skipped {$skippedCount}.";
|
||||
}
|
||||
@ -1167,18 +1194,20 @@ public static function table(Table $table): Table
|
||||
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('close_selected')
|
||||
->label('Close selected')
|
||||
->label(GovernanceActionCatalog::rule('close_finding')->canonicalLabel.' selected')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(GovernanceActionCatalog::rule('close_finding')->modalHeading)
|
||||
->modalDescription(GovernanceActionCatalog::rule('close_finding')->modalDescription)
|
||||
->form([
|
||||
Textarea::make('closed_reason')
|
||||
Select::make('closed_reason')
|
||||
->label('Close reason')
|
||||
->rows(3)
|
||||
->options(static::closeReasonOptions())
|
||||
->helperText('Use the canonical administrative closure outcome for this finding.')
|
||||
->native(false)
|
||||
->required()
|
||||
->maxLength(255),
|
||||
->selectablePlaceholder(false),
|
||||
])
|
||||
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -1448,24 +1477,30 @@ public static function assignAction(): Actions\Action
|
||||
|
||||
public static function resolveAction(): Actions\Action
|
||||
{
|
||||
$rule = GovernanceActionCatalog::rule('resolve_finding');
|
||||
|
||||
return UiEnforcement::forAction(
|
||||
Actions\Action::make('resolve')
|
||||
->label('Resolve')
|
||||
->label($rule->canonicalLabel)
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||
->requiresConfirmation()
|
||||
->modalHeading($rule->modalHeading)
|
||||
->modalDescription($rule->modalDescription)
|
||||
->form([
|
||||
Textarea::make('resolved_reason')
|
||||
->label('Resolution reason')
|
||||
->rows(3)
|
||||
Select::make('resolved_reason')
|
||||
->label('Resolution outcome')
|
||||
->options(static::resolveReasonOptions())
|
||||
->helperText('Use the canonical manual remediation outcome. Trusted verification is recorded later by system evidence.')
|
||||
->native(false)
|
||||
->required()
|
||||
->maxLength(255),
|
||||
->selectablePlaceholder(false),
|
||||
])
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
||||
static::runWorkflowMutation(
|
||||
record: $record,
|
||||
successTitle: 'Finding resolved',
|
||||
successTitle: $rule->successTitle,
|
||||
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->resolve(
|
||||
$finding,
|
||||
$tenant,
|
||||
@ -1495,11 +1530,13 @@ public static function closeAction(): Actions\Action
|
||||
->modalHeading($rule->modalHeading)
|
||||
->modalDescription($rule->modalDescription)
|
||||
->form([
|
||||
Textarea::make('closed_reason')
|
||||
Select::make('closed_reason')
|
||||
->label('Close reason')
|
||||
->rows(3)
|
||||
->options(static::closeReasonOptions())
|
||||
->helperText('Use the canonical administrative closure outcome for this finding.')
|
||||
->native(false)
|
||||
->required()
|
||||
->maxLength(255),
|
||||
->selectablePlaceholder(false),
|
||||
])
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
||||
static::runWorkflowMutation(
|
||||
@ -1694,12 +1731,17 @@ public static function reopenAction(): Actions\Action
|
||||
->modalHeading($rule->modalHeading)
|
||||
->modalDescription($rule->modalDescription)
|
||||
->visible(fn (Finding $record): bool => Finding::isTerminalStatus((string) $record->status))
|
||||
->fillForm([
|
||||
'reopen_reason' => Finding::REOPEN_REASON_MANUAL_REASSESSMENT,
|
||||
])
|
||||
->form([
|
||||
Textarea::make('reopen_reason')
|
||||
Select::make('reopen_reason')
|
||||
->label('Reopen reason')
|
||||
->rows(3)
|
||||
->options(static::reopenReasonOptions())
|
||||
->helperText('Use the canonical reopen reason that best describes why this finding needs active workflow again.')
|
||||
->native(false)
|
||||
->required()
|
||||
->maxLength(255),
|
||||
->selectablePlaceholder(false),
|
||||
])
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
||||
static::runWorkflowMutation(
|
||||
@ -2138,6 +2180,150 @@ private static function governanceValidityState(Finding $finding): ?string
|
||||
->resolveGovernanceValidity($finding, static::resolvedFindingException($finding));
|
||||
}
|
||||
|
||||
private static function findingOutcomeSemantics(): FindingOutcomeSemantics
|
||||
{
|
||||
return app(FindingOutcomeSemantics::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* terminal_outcome_key: ?string,
|
||||
* label: ?string,
|
||||
* verification_state: string,
|
||||
* verification_label: ?string,
|
||||
* report_bucket: ?string
|
||||
* }
|
||||
*/
|
||||
private static function findingOutcome(Finding $finding): array
|
||||
{
|
||||
return static::findingOutcomeSemantics()->describe($finding);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function resolveReasonOptions(): array
|
||||
{
|
||||
return [
|
||||
Finding::RESOLVE_REASON_REMEDIATED => 'Remediated',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function closeReasonOptions(): array
|
||||
{
|
||||
return [
|
||||
Finding::CLOSE_REASON_FALSE_POSITIVE => 'False positive',
|
||||
Finding::CLOSE_REASON_DUPLICATE => 'Duplicate',
|
||||
Finding::CLOSE_REASON_NO_LONGER_APPLICABLE => 'No longer applicable',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function reopenReasonOptions(): array
|
||||
{
|
||||
return [
|
||||
Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION => 'Recurred after resolution',
|
||||
Finding::REOPEN_REASON_VERIFICATION_FAILED => 'Verification failed',
|
||||
Finding::REOPEN_REASON_MANUAL_REASSESSMENT => 'Manual reassessment',
|
||||
];
|
||||
}
|
||||
|
||||
private static function resolveReasonLabel(?string $reason): ?string
|
||||
{
|
||||
return static::resolveReasonOptions()[$reason] ?? match ($reason) {
|
||||
Finding::RESOLVE_REASON_NO_LONGER_DRIFTING => 'No longer drifting',
|
||||
Finding::RESOLVE_REASON_PERMISSION_GRANTED => 'Permission granted',
|
||||
Finding::RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY => 'Permission removed from registry',
|
||||
Finding::RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED => 'Role assignment removed',
|
||||
Finding::RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD => 'GA count within threshold',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static function closeReasonLabel(?string $reason): ?string
|
||||
{
|
||||
return static::closeReasonOptions()[$reason] ?? match ($reason) {
|
||||
Finding::CLOSE_REASON_ACCEPTED_RISK => 'Accepted risk',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static function reopenReasonLabel(?string $reason): ?string
|
||||
{
|
||||
return static::reopenReasonOptions()[$reason] ?? null;
|
||||
}
|
||||
|
||||
private static function terminalOutcomeLabel(Finding $finding): ?string
|
||||
{
|
||||
return static::findingOutcome($finding)['label'] ?? null;
|
||||
}
|
||||
|
||||
private static function verificationStateLabel(Finding $finding): ?string
|
||||
{
|
||||
return static::findingOutcome($finding)['verification_label'] ?? null;
|
||||
}
|
||||
|
||||
private static function statusDescription(Finding $finding): string
|
||||
{
|
||||
return static::terminalOutcomeLabel($finding) ?? static::primaryNarrative($finding);
|
||||
}
|
||||
|
||||
private static function applyTerminalOutcomeFilter(Builder $query, mixed $value): Builder
|
||||
{
|
||||
if (! is_string($value) || $value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return match ($value) {
|
||||
FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => $query
|
||||
->where('status', Finding::STATUS_RESOLVED)
|
||||
->whereIn('resolved_reason', Finding::manualResolveReasonKeys()),
|
||||
FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => $query
|
||||
->where('status', Finding::STATUS_RESOLVED)
|
||||
->whereIn('resolved_reason', Finding::systemResolveReasonKeys()),
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE => $query
|
||||
->where('status', Finding::STATUS_CLOSED)
|
||||
->where('closed_reason', Finding::CLOSE_REASON_FALSE_POSITIVE),
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE => $query
|
||||
->where('status', Finding::STATUS_CLOSED)
|
||||
->where('closed_reason', Finding::CLOSE_REASON_DUPLICATE),
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => $query
|
||||
->where('status', Finding::STATUS_CLOSED)
|
||||
->where('closed_reason', Finding::CLOSE_REASON_NO_LONGER_APPLICABLE),
|
||||
FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED => $query
|
||||
->where('status', Finding::STATUS_RISK_ACCEPTED),
|
||||
default => $query,
|
||||
};
|
||||
}
|
||||
|
||||
private static function applyVerificationStateFilter(Builder $query, mixed $value): Builder
|
||||
{
|
||||
if (! is_string($value) || $value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return match ($value) {
|
||||
FindingOutcomeSemantics::VERIFICATION_PENDING => $query
|
||||
->where('status', Finding::STATUS_RESOLVED)
|
||||
->whereIn('resolved_reason', Finding::manualResolveReasonKeys()),
|
||||
FindingOutcomeSemantics::VERIFICATION_VERIFIED => $query
|
||||
->where('status', Finding::STATUS_RESOLVED)
|
||||
->whereIn('resolved_reason', Finding::systemResolveReasonKeys()),
|
||||
FindingOutcomeSemantics::VERIFICATION_NOT_APPLICABLE => $query->where(function (Builder $verificationQuery): void {
|
||||
$verificationQuery
|
||||
->where('status', '!=', Finding::STATUS_RESOLVED)
|
||||
->orWhereNull('resolved_reason')
|
||||
->orWhereNotIn('resolved_reason', Finding::resolveReasonKeys());
|
||||
}),
|
||||
default => $query,
|
||||
};
|
||||
}
|
||||
|
||||
private static function primaryNarrative(Finding $finding): string
|
||||
{
|
||||
return app(FindingRiskGovernanceResolver::class)
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
@ -540,12 +541,19 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
$summary = is_array($record->summary) ? $record->summary : [];
|
||||
$truthEnvelope = static::truthEnvelope($record);
|
||||
$reasonPresenter = app(ReasonPresenter::class);
|
||||
$highlights = is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [];
|
||||
$findingOutcomeSummary = static::findingOutcomeSummary($summary);
|
||||
$findingOutcomes = is_array($summary['finding_outcomes'] ?? null) ? $summary['finding_outcomes'] : [];
|
||||
|
||||
if ($findingOutcomeSummary !== null) {
|
||||
$highlights[] = 'Terminal outcomes: '.$findingOutcomeSummary.'.';
|
||||
}
|
||||
|
||||
return [
|
||||
'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
|
||||
'compressed_outcome' => static::compressedOutcome($record)->toArray(),
|
||||
'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
|
||||
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
|
||||
'highlights' => $highlights,
|
||||
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
|
||||
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],
|
||||
'context_links' => static::summaryContextLinks($record),
|
||||
@ -554,6 +562,8 @@ private static function summaryPresentation(TenantReview $record): array
|
||||
['label' => 'Reports', 'value' => (string) ($summary['report_count'] ?? 0)],
|
||||
['label' => 'Operations', 'value' => (string) ($summary['operation_count'] ?? 0)],
|
||||
['label' => 'Sections', 'value' => (string) ($summary['section_count'] ?? 0)],
|
||||
['label' => 'Pending verification', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION] ?? 0)],
|
||||
['label' => 'Verified cleared', 'value' => (string) ($findingOutcomes[FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED] ?? 0)],
|
||||
],
|
||||
];
|
||||
}
|
||||
@ -655,4 +665,18 @@ private static function compressedOutcome(TenantReview $record, bool $fresh = fa
|
||||
SurfaceCompressionContext::tenantReview(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $summary
|
||||
*/
|
||||
private static function findingOutcomeSummary(array $summary): ?string
|
||||
{
|
||||
$outcomeCounts = $summary['finding_outcomes'] ?? [];
|
||||
|
||||
if (! is_array($outcomeCounts)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return app(FindingOutcomeSemantics::class)->compactOutcomeSummary($outcomeCounts);
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Alerts\AlertDispatchService;
|
||||
use App\Services\Findings\FindingNotificationService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Settings\SettingsResolver;
|
||||
use App\Support\OperationRunOutcome;
|
||||
@ -21,6 +22,7 @@
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
use Throwable;
|
||||
|
||||
class EvaluateAlertsJob implements ShouldQueue
|
||||
@ -32,7 +34,11 @@ public function __construct(
|
||||
public ?int $operationRunId = null,
|
||||
) {}
|
||||
|
||||
public function handle(AlertDispatchService $dispatchService, OperationRunService $operationRuns): void
|
||||
public function handle(
|
||||
AlertDispatchService $dispatchService,
|
||||
OperationRunService $operationRuns,
|
||||
FindingNotificationService $findingNotificationService,
|
||||
): void
|
||||
{
|
||||
$workspace = Workspace::query()->whereKey($this->workspaceId)->first();
|
||||
|
||||
@ -67,6 +73,8 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
|
||||
...$this->permissionMissingEvents((int) $workspace->getKey(), $windowStart),
|
||||
...$this->entraAdminRolesHighEvents((int) $workspace->getKey(), $windowStart),
|
||||
];
|
||||
$dueSoonFindings = $this->dueSoonFindings((int) $workspace->getKey());
|
||||
$overdueFindings = $this->overdueFindings((int) $workspace->getKey());
|
||||
|
||||
$createdDeliveries = 0;
|
||||
|
||||
@ -74,13 +82,33 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
|
||||
$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(
|
||||
$operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: [
|
||||
'total' => count($events),
|
||||
'processed' => count($events),
|
||||
'total' => $processedEventCount,
|
||||
'processed' => $processedEventCount,
|
||||
'created' => $createdDeliveries,
|
||||
],
|
||||
);
|
||||
@ -101,6 +129,45 @@ public function handle(AlertDispatchService $dispatchService, OperationRunServic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
if (is_int($this->operationRunId) && $this->operationRunId > 0) {
|
||||
|
||||
@ -345,9 +345,12 @@ private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $bac
|
||||
}
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => $backfillStartedAt,
|
||||
'resolved_reason' => 'consolidated_duplicate',
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'closed_at' => $backfillStartedAt,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
'closed_by_user_id' => null,
|
||||
'recurrence_key' => null,
|
||||
])->save();
|
||||
|
||||
|
||||
@ -325,9 +325,12 @@ private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $bac
|
||||
}
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => $backfillStartedAt,
|
||||
'resolved_reason' => 'consolidated_duplicate',
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'closed_at' => $backfillStartedAt,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
'closed_by_user_id' => null,
|
||||
'recurrence_key' => null,
|
||||
])->save();
|
||||
|
||||
|
||||
@ -28,6 +28,14 @@ class AlertRule extends Model
|
||||
|
||||
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_ALLOWLIST = 'allowlist';
|
||||
|
||||
@ -47,6 +47,32 @@ class Finding extends Model
|
||||
|
||||
public const string STATUS_RISK_ACCEPTED = 'risk_accepted';
|
||||
|
||||
public const string RESOLVE_REASON_REMEDIATED = 'remediated';
|
||||
|
||||
public const string RESOLVE_REASON_NO_LONGER_DRIFTING = 'no_longer_drifting';
|
||||
|
||||
public const string RESOLVE_REASON_PERMISSION_GRANTED = 'permission_granted';
|
||||
|
||||
public const string RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY = 'permission_removed_from_registry';
|
||||
|
||||
public const string RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED = 'role_assignment_removed';
|
||||
|
||||
public const string RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD = 'ga_count_within_threshold';
|
||||
|
||||
public const string CLOSE_REASON_FALSE_POSITIVE = 'false_positive';
|
||||
|
||||
public const string CLOSE_REASON_DUPLICATE = 'duplicate';
|
||||
|
||||
public const string CLOSE_REASON_NO_LONGER_APPLICABLE = 'no_longer_applicable';
|
||||
|
||||
public const string CLOSE_REASON_ACCEPTED_RISK = 'accepted_risk';
|
||||
|
||||
public const string REOPEN_REASON_RECURRED_AFTER_RESOLUTION = 'recurred_after_resolution';
|
||||
|
||||
public const string REOPEN_REASON_VERIFICATION_FAILED = 'verification_failed';
|
||||
|
||||
public const string REOPEN_REASON_MANUAL_REASSESSMENT = 'manual_reassessment';
|
||||
|
||||
public const string RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY = 'orphaned_accountability';
|
||||
|
||||
public const string RESPONSIBILITY_STATE_OWNED_UNASSIGNED = 'owned_unassigned';
|
||||
@ -160,6 +186,113 @@ public static function highSeverityValues(): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function manualResolveReasonKeys(): array
|
||||
{
|
||||
return [
|
||||
self::RESOLVE_REASON_REMEDIATED,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function systemResolveReasonKeys(): array
|
||||
{
|
||||
return [
|
||||
self::RESOLVE_REASON_NO_LONGER_DRIFTING,
|
||||
self::RESOLVE_REASON_PERMISSION_GRANTED,
|
||||
self::RESOLVE_REASON_PERMISSION_REMOVED_FROM_REGISTRY,
|
||||
self::RESOLVE_REASON_ROLE_ASSIGNMENT_REMOVED,
|
||||
self::RESOLVE_REASON_GA_COUNT_WITHIN_THRESHOLD,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function resolveReasonKeys(): array
|
||||
{
|
||||
return [
|
||||
...self::manualResolveReasonKeys(),
|
||||
...self::systemResolveReasonKeys(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function closeReasonKeys(): array
|
||||
{
|
||||
return [
|
||||
self::CLOSE_REASON_FALSE_POSITIVE,
|
||||
self::CLOSE_REASON_DUPLICATE,
|
||||
self::CLOSE_REASON_NO_LONGER_APPLICABLE,
|
||||
self::CLOSE_REASON_ACCEPTED_RISK,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function manualCloseReasonKeys(): array
|
||||
{
|
||||
return [
|
||||
self::CLOSE_REASON_FALSE_POSITIVE,
|
||||
self::CLOSE_REASON_DUPLICATE,
|
||||
self::CLOSE_REASON_NO_LONGER_APPLICABLE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function reopenReasonKeys(): array
|
||||
{
|
||||
return [
|
||||
self::REOPEN_REASON_RECURRED_AFTER_RESOLUTION,
|
||||
self::REOPEN_REASON_VERIFICATION_FAILED,
|
||||
self::REOPEN_REASON_MANUAL_REASSESSMENT,
|
||||
];
|
||||
}
|
||||
|
||||
public static function isResolveReason(?string $reason): bool
|
||||
{
|
||||
return is_string($reason) && in_array($reason, self::resolveReasonKeys(), true);
|
||||
}
|
||||
|
||||
public static function isManualResolveReason(?string $reason): bool
|
||||
{
|
||||
return is_string($reason) && in_array($reason, self::manualResolveReasonKeys(), true);
|
||||
}
|
||||
|
||||
public static function isSystemResolveReason(?string $reason): bool
|
||||
{
|
||||
return is_string($reason) && in_array($reason, self::systemResolveReasonKeys(), true);
|
||||
}
|
||||
|
||||
public static function isCloseReason(?string $reason): bool
|
||||
{
|
||||
return is_string($reason) && in_array($reason, self::closeReasonKeys(), true);
|
||||
}
|
||||
|
||||
public static function isManualCloseReason(?string $reason): bool
|
||||
{
|
||||
return is_string($reason) && in_array($reason, self::manualCloseReasonKeys(), true);
|
||||
}
|
||||
|
||||
public static function isRiskAcceptedReason(?string $reason): bool
|
||||
{
|
||||
return $reason === self::CLOSE_REASON_ACCEPTED_RISK;
|
||||
}
|
||||
|
||||
public static function isReopenReason(?string $reason): bool
|
||||
{
|
||||
return is_string($reason) && in_array($reason, self::reopenReasonKeys(), true);
|
||||
}
|
||||
|
||||
public static function canonicalizeStatus(?string $status): ?string
|
||||
{
|
||||
if ($status === self::STATUS_ACKNOWLEDGED) {
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
<?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,12 +3,8 @@
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
@ -27,25 +23,7 @@ public function via(object $notifiable): array
|
||||
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
$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();
|
||||
$message = OperationUxPresenter::terminalDatabaseNotificationMessage($this->run, $notifiable);
|
||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
|
||||
|
||||
if ($reasonEnvelope !== null) {
|
||||
|
||||
@ -3,10 +3,7 @@
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
@ -31,31 +28,6 @@ public function via(object $notifiable): array
|
||||
*/
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
$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();
|
||||
return OperationUxPresenter::queuedDatabaseNotificationMessage($this->run, $notifiable);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
use App\Filament\Pages\Auth\Login;
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
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\InventoryCoverage;
|
||||
@ -178,6 +179,7 @@ public function panel(Panel $panel): Panel
|
||||
InventoryCoverage::class,
|
||||
TenantRequiredPermissions::class,
|
||||
WorkspaceSettings::class,
|
||||
FindingsHygieneReport::class,
|
||||
FindingsIntakeQueue::class,
|
||||
MyFindingsInbox::class,
|
||||
FindingExceptionsQueue::class,
|
||||
|
||||
@ -186,6 +186,8 @@ private function buildPayload(array $event): array
|
||||
return [
|
||||
'title' => $title,
|
||||
'body' => $body,
|
||||
'event_type' => trim((string) ($event['event_type'] ?? '')),
|
||||
'fingerprint_key' => trim((string) ($event['fingerprint_key'] ?? '')),
|
||||
'metadata' => $metadata,
|
||||
];
|
||||
}
|
||||
|
||||
@ -213,6 +213,12 @@ public function buildSnapshotPayload(Tenant $tenant): array
|
||||
'state' => $item['state'],
|
||||
'required' => $item['required'],
|
||||
], $items),
|
||||
'finding_outcomes' => is_array($findingsSummary['outcome_counts'] ?? null)
|
||||
? $findingsSummary['outcome_counts']
|
||||
: [],
|
||||
'finding_report_buckets' => is_array($findingsSummary['report_bucket_counts'] ?? null)
|
||||
? $findingsSummary['report_bucket_counts']
|
||||
: [],
|
||||
'risk_acceptance' => is_array($findingsSummary['risk_acceptance'] ?? null)
|
||||
? $findingsSummary['risk_acceptance']
|
||||
: [
|
||||
|
||||
@ -8,12 +8,14 @@
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Evidence\Contracts\EvidenceSourceProvider;
|
||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
|
||||
final class FindingsSummarySource implements EvidenceSourceProvider
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FindingRiskGovernanceResolver $governanceResolver,
|
||||
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
||||
) {}
|
||||
|
||||
public function key(): string
|
||||
@ -33,6 +35,7 @@ public function collect(Tenant $tenant): array
|
||||
$entries = $findings->map(function (Finding $finding): array {
|
||||
$governanceState = $this->governanceResolver->resolveFindingState($finding, $finding->findingException);
|
||||
$governanceWarning = $this->governanceResolver->resolveWarningMessage($finding, $finding->findingException);
|
||||
$outcome = $this->findingOutcomeSemantics->describe($finding);
|
||||
|
||||
return [
|
||||
'id' => (int) $finding->getKey(),
|
||||
@ -43,10 +46,42 @@ public function collect(Tenant $tenant): array
|
||||
'description' => $finding->description,
|
||||
'created_at' => $finding->created_at?->toIso8601String(),
|
||||
'updated_at' => $finding->updated_at?->toIso8601String(),
|
||||
'verification_state' => $outcome['verification_state'],
|
||||
'report_bucket' => $outcome['report_bucket'],
|
||||
'terminal_outcome_key' => $outcome['terminal_outcome_key'],
|
||||
'terminal_outcome_label' => $outcome['label'],
|
||||
'terminal_outcome' => $outcome['terminal_outcome_key'] !== null ? [
|
||||
'key' => $outcome['terminal_outcome_key'],
|
||||
'label' => $outcome['label'],
|
||||
'verification_state' => $outcome['verification_state'],
|
||||
'report_bucket' => $outcome['report_bucket'],
|
||||
'governance_state' => $governanceState,
|
||||
] : null,
|
||||
'governance_state' => $governanceState,
|
||||
'governance_warning' => $governanceWarning,
|
||||
];
|
||||
});
|
||||
$outcomeCounts = array_fill_keys($this->findingOutcomeSemantics->orderedOutcomeKeys(), 0);
|
||||
$reportBucketCounts = [
|
||||
'remediation_pending_verification' => 0,
|
||||
'remediation_verified' => 0,
|
||||
'administrative_closure' => 0,
|
||||
'accepted_risk' => 0,
|
||||
];
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
$terminalOutcomeKey = $entry['terminal_outcome_key'] ?? null;
|
||||
$reportBucket = $entry['report_bucket'] ?? null;
|
||||
|
||||
if (is_string($terminalOutcomeKey) && array_key_exists($terminalOutcomeKey, $outcomeCounts)) {
|
||||
$outcomeCounts[$terminalOutcomeKey]++;
|
||||
}
|
||||
|
||||
if (is_string($reportBucket) && array_key_exists($reportBucket, $reportBucketCounts)) {
|
||||
$reportBucketCounts[$reportBucket]++;
|
||||
}
|
||||
}
|
||||
|
||||
$riskAcceptedEntries = $entries->filter(
|
||||
static fn (array $entry): bool => ($entry['status'] ?? null) === Finding::STATUS_RISK_ACCEPTED,
|
||||
);
|
||||
@ -78,6 +113,8 @@ public function collect(Tenant $tenant): array
|
||||
'revoked_count' => $riskAcceptedEntries->where('governance_state', 'revoked_exception')->count(),
|
||||
'missing_exception_count' => $riskAcceptedEntries->where('governance_state', 'risk_accepted_without_valid_exception')->count(),
|
||||
],
|
||||
'outcome_counts' => $outcomeCounts,
|
||||
'report_bucket_counts' => $reportBucketCounts,
|
||||
'entries' => $entries->all(),
|
||||
];
|
||||
|
||||
|
||||
@ -0,0 +1,306 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@ -857,7 +857,7 @@ private function evidenceSummary(array $references): array
|
||||
|
||||
private function findingRiskAcceptedReason(string $approvalReason): string
|
||||
{
|
||||
return mb_substr($approvalReason, 0, 255);
|
||||
return Finding::CLOSE_REASON_ACCEPTED_RISK;
|
||||
}
|
||||
|
||||
private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable
|
||||
|
||||
@ -0,0 +1,389 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -7,11 +7,16 @@
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\FindingExceptionDecision;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
final class FindingRiskGovernanceResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
||||
) {}
|
||||
|
||||
public function resolveWorkflowFamily(Finding $finding): string
|
||||
{
|
||||
return match (Finding::canonicalizeStatus((string) $finding->status)) {
|
||||
@ -218,11 +223,7 @@ public function resolvePrimaryNarrative(Finding $finding, ?FindingException $exc
|
||||
'accepted_risk' => $this->resolveGovernanceAttention($finding, $exception, $now) === 'healthy'
|
||||
? 'Accepted risk remains visible because current governance is still valid.'
|
||||
: 'Accepted risk is still on record, but governance follow-up is needed before it can be treated as safe to ignore.',
|
||||
'historical' => match ((string) $finding->status) {
|
||||
Finding::STATUS_RESOLVED => 'Resolved is a historical workflow state. It does not prove the issue is permanently gone.',
|
||||
Finding::STATUS_CLOSED => 'Closed is a historical workflow state. It does not prove the issue is permanently gone.',
|
||||
default => 'This finding is historical workflow context.',
|
||||
},
|
||||
'historical' => $this->historicalPrimaryNarrative($finding),
|
||||
default => match ($finding->responsibilityState()) {
|
||||
Finding::RESPONSIBILITY_STATE_ORPHANED_ACCOUNTABILITY => 'This finding is still active workflow work and currently has orphaned accountability.',
|
||||
Finding::RESPONSIBILITY_STATE_OWNED_UNASSIGNED => 'This finding has an accountable owner, but the active remediation work is still unassigned.',
|
||||
@ -253,8 +254,14 @@ public function resolvePrimaryNextAction(Finding $finding, ?FindingException $ex
|
||||
};
|
||||
}
|
||||
|
||||
if ((string) $finding->status === Finding::STATUS_RESOLVED || (string) $finding->status === Finding::STATUS_CLOSED) {
|
||||
return 'Review the closure context and reopen the finding if the issue has returned or governance has lapsed.';
|
||||
if ((string) $finding->status === Finding::STATUS_RESOLVED) {
|
||||
return $this->findingOutcomeSemantics->verificationState($finding) === FindingOutcomeSemantics::VERIFICATION_PENDING
|
||||
? 'Wait for later trusted evidence to confirm the issue is actually clear, or reopen the finding if verification still fails.'
|
||||
: 'Keep the finding closed unless later trusted evidence shows the issue has returned.';
|
||||
}
|
||||
|
||||
if ((string) $finding->status === Finding::STATUS_CLOSED) {
|
||||
return 'Review the administrative closure context and reopen the finding if the tenant reality no longer matches that decision.';
|
||||
}
|
||||
|
||||
return match ($finding->responsibilityState()) {
|
||||
@ -340,23 +347,33 @@ private function renewalAwareDate(FindingException $exception, string $metadataK
|
||||
|
||||
private function resolvedHistoricalContext(Finding $finding): ?string
|
||||
{
|
||||
$reason = (string) ($finding->resolved_reason ?? '');
|
||||
|
||||
return match ($reason) {
|
||||
'no_longer_drifting' => 'The latest compare did not reproduce the earlier drift, but treat this as the last observed workflow outcome rather than a permanent guarantee.',
|
||||
'permission_granted',
|
||||
'permission_removed_from_registry',
|
||||
'role_assignment_removed',
|
||||
'ga_count_within_threshold' => 'The last observed workflow reason suggests the triggering condition was no longer present, but this remains historical context.',
|
||||
return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) {
|
||||
FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'This finding was resolved manually and is still waiting for trusted evidence to confirm the issue is actually gone.',
|
||||
FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => 'Trusted evidence later confirmed the triggering condition was no longer present at the last observed check.',
|
||||
default => 'Resolved records the workflow outcome. Review the reason and latest evidence before treating it as technical proof.',
|
||||
};
|
||||
}
|
||||
|
||||
private function closedHistoricalContext(Finding $finding): ?string
|
||||
{
|
||||
return match ((string) ($finding->closed_reason ?? '')) {
|
||||
'accepted_risk' => 'Closed reflects workflow handling. Governance validity still determines whether accepted risk remains safe to rely on.',
|
||||
return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) {
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE => 'This finding was closed as a false positive, which is an administrative closure rather than proof of remediation.',
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE => 'This finding was closed as a duplicate, which is an administrative closure rather than proof of remediation.',
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'This finding was closed as no longer applicable, which is an administrative closure rather than proof of remediation.',
|
||||
FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED => 'Closed reflects workflow handling. Governance validity still determines whether accepted risk remains safe to rely on.',
|
||||
default => 'Closed records the workflow outcome. Review the recorded reason before treating it as technical proof.',
|
||||
};
|
||||
}
|
||||
|
||||
private function historicalPrimaryNarrative(Finding $finding): string
|
||||
{
|
||||
return match ($this->findingOutcomeSemantics->terminalOutcomeKey($finding)) {
|
||||
FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'Resolved pending verification means an operator declared the remediation complete, but trusted verification has not confirmed it yet.',
|
||||
FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED => 'Verified cleared means trusted evidence later confirmed the issue was no longer present.',
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_FALSE_POSITIVE,
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE,
|
||||
FindingOutcomeSemantics::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'This finding is closed for an administrative reason and should not be read as a remediation outcome.',
|
||||
default => 'This finding is historical workflow context.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
namespace App\Services\Findings;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantMembership;
|
||||
use App\Models\User;
|
||||
@ -13,6 +14,7 @@
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Audit\AuditActorType;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -26,8 +28,22 @@ public function __construct(
|
||||
private readonly FindingSlaPolicy $slaPolicy,
|
||||
private readonly AuditLogger $auditLogger,
|
||||
private readonly CapabilityResolver $capabilityResolver,
|
||||
private readonly FindingNotificationService $findingNotificationService,
|
||||
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @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
|
||||
{
|
||||
$this->authorize($finding, $tenant, $actor, [
|
||||
@ -108,6 +124,7 @@ public function assign(
|
||||
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, $ownerUserId, 'owner_user_id');
|
||||
|
||||
@ -124,7 +141,7 @@ public function assign(
|
||||
afterAssigneeUserId: $assigneeUserId,
|
||||
);
|
||||
|
||||
return $this->mutateAndAudit(
|
||||
$updatedFinding = $this->mutateAndAudit(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
actor: $actor,
|
||||
@ -142,6 +159,16 @@ public function assign(
|
||||
$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
|
||||
@ -248,7 +275,7 @@ public function resolve(Finding $finding, Tenant $tenant, User $actor, string $r
|
||||
throw new InvalidArgumentException('Only open findings can be resolved.');
|
||||
}
|
||||
|
||||
$reason = $this->validatedReason($reason, 'resolved_reason');
|
||||
$reason = $this->validatedReason($reason, 'resolved_reason', Finding::manualResolveReasonKeys());
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
return $this->mutateAndAudit(
|
||||
@ -274,7 +301,7 @@ public function close(Finding $finding, Tenant $tenant, User $actor, string $rea
|
||||
{
|
||||
$this->authorize($finding, $tenant, $actor, [Capabilities::TENANT_FINDINGS_CLOSE]);
|
||||
|
||||
$reason = $this->validatedReason($reason, 'closed_reason');
|
||||
$reason = $this->validatedReason($reason, 'closed_reason', Finding::manualCloseReasonKeys());
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
return $this->mutateAndAudit(
|
||||
@ -317,7 +344,7 @@ private function riskAcceptWithoutAuthorization(Finding $finding, Tenant $tenant
|
||||
throw new InvalidArgumentException('Only open findings can be marked as risk accepted.');
|
||||
}
|
||||
|
||||
$reason = $this->validatedReason($reason, 'closed_reason');
|
||||
$reason = $this->validatedReason($reason, 'closed_reason', [Finding::CLOSE_REASON_ACCEPTED_RISK]);
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
return $this->mutateAndAudit(
|
||||
@ -351,7 +378,7 @@ public function reopen(Finding $finding, Tenant $tenant, User $actor, string $re
|
||||
throw new InvalidArgumentException('Only terminal findings can be reopened.');
|
||||
}
|
||||
|
||||
$reason = $this->validatedReason($reason, 'reopen_reason');
|
||||
$reason = $this->validatedReason($reason, 'reopen_reason', Finding::reopenReasonKeys());
|
||||
$now = CarbonImmutable::now();
|
||||
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
|
||||
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $now);
|
||||
@ -393,11 +420,11 @@ public function resolveBySystem(
|
||||
): Finding {
|
||||
$this->assertFindingOwnedByTenant($finding, $tenant);
|
||||
|
||||
if (! $finding->hasOpenStatus()) {
|
||||
throw new InvalidArgumentException('Only open findings can be resolved.');
|
||||
if (! $finding->hasOpenStatus() && (string) $finding->status !== Finding::STATUS_RESOLVED) {
|
||||
throw new InvalidArgumentException('Only open or manually resolved findings can be system-cleared.');
|
||||
}
|
||||
|
||||
$reason = $this->validatedReason($reason, 'resolved_reason');
|
||||
$reason = $this->validatedReason($reason, 'resolved_reason', Finding::systemResolveReasonKeys());
|
||||
|
||||
return $this->mutateAndAudit(
|
||||
finding: $finding,
|
||||
@ -431,6 +458,7 @@ public function reopenBySystem(
|
||||
CarbonImmutable $reopenedAt,
|
||||
?int $operationRunId = null,
|
||||
?callable $mutate = null,
|
||||
?string $reason = null,
|
||||
): Finding {
|
||||
$this->assertFindingOwnedByTenant($finding, $tenant);
|
||||
|
||||
@ -438,10 +466,15 @@ public function reopenBySystem(
|
||||
throw new InvalidArgumentException('Only terminal findings can be reopened.');
|
||||
}
|
||||
|
||||
$reason = $this->validatedReason(
|
||||
$reason ?? $this->findingOutcomeSemantics->systemReopenReasonFor($finding),
|
||||
'reopen_reason',
|
||||
Finding::reopenReasonKeys(),
|
||||
);
|
||||
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
|
||||
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $reopenedAt);
|
||||
|
||||
return $this->mutateAndAudit(
|
||||
$reopenedFinding = $this->mutateAndAudit(
|
||||
finding: $finding,
|
||||
tenant: $tenant,
|
||||
actor: null,
|
||||
@ -449,6 +482,7 @@ public function reopenBySystem(
|
||||
context: [
|
||||
'metadata' => [
|
||||
'reopened_at' => $reopenedAt->toIso8601String(),
|
||||
'reopened_reason' => $reason,
|
||||
'sla_days' => $slaDays,
|
||||
'due_at' => $dueAt->toIso8601String(),
|
||||
'system_origin' => true,
|
||||
@ -472,6 +506,30 @@ public function reopenBySystem(
|
||||
actorType: AuditActorType::System,
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
@ -525,7 +583,10 @@ private function assertTenantMemberOrNull(Tenant $tenant, ?int $userId, string $
|
||||
}
|
||||
}
|
||||
|
||||
private function validatedReason(string $reason, string $field): string
|
||||
/**
|
||||
* @param array<int, string> $allowedReasons
|
||||
*/
|
||||
private function validatedReason(string $reason, string $field, array $allowedReasons): string
|
||||
{
|
||||
$reason = trim($reason);
|
||||
|
||||
@ -537,9 +598,38 @@ private function validatedReason(string $reason, string $field): string
|
||||
throw new InvalidArgumentException(sprintf('%s must be at most 255 characters.', $field));
|
||||
}
|
||||
|
||||
if (! in_array($reason, $allowedReasons, true)) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'%s must be one of: %s.',
|
||||
$field,
|
||||
implode(', ', $allowedReasons),
|
||||
));
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
@ -567,12 +657,17 @@ private function mutateAndAudit(
|
||||
$record->save();
|
||||
|
||||
$after = $this->auditSnapshot($record);
|
||||
$outcome = $this->findingOutcomeSemantics->describe($record);
|
||||
$auditMetadata = array_merge($metadata, [
|
||||
'finding_id' => (int) $record->getKey(),
|
||||
'before_status' => $before['status'] ?? null,
|
||||
'after_status' => $after['status'] ?? null,
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
'terminal_outcome_key' => $outcome['terminal_outcome_key'],
|
||||
'terminal_outcome_label' => $outcome['label'],
|
||||
'verification_state' => $outcome['verification_state'],
|
||||
'report_bucket' => $outcome['report_bucket'],
|
||||
'_dedupe_key' => $this->dedupeKey($action, $record, $before, $after, $metadata, $actor, $actorType),
|
||||
]);
|
||||
|
||||
@ -643,6 +738,7 @@ private function dedupeKey(
|
||||
'owner_user_id' => $metadata['owner_user_id'] ?? null,
|
||||
'resolved_reason' => $metadata['resolved_reason'] ?? null,
|
||||
'closed_reason' => $metadata['closed_reason'] ?? null,
|
||||
'reopened_reason' => $metadata['reopened_reason'] ?? null,
|
||||
];
|
||||
|
||||
$encoded = json_encode($payload);
|
||||
|
||||
@ -83,6 +83,12 @@ public function generate(Tenant $tenant, User $user, array $options = []): Revie
|
||||
'status' => ReviewPackStatus::Queued->value,
|
||||
'options' => $options,
|
||||
'summary' => [
|
||||
'finding_outcomes' => is_array($snapshot->summary['finding_outcomes'] ?? null)
|
||||
? $snapshot->summary['finding_outcomes']
|
||||
: [],
|
||||
'finding_report_buckets' => is_array($snapshot->summary['finding_report_buckets'] ?? null)
|
||||
? $snapshot->summary['finding_report_buckets']
|
||||
: [],
|
||||
'risk_acceptance' => is_array($snapshot->summary['risk_acceptance'] ?? null)
|
||||
? $snapshot->summary['risk_acceptance']
|
||||
: [],
|
||||
@ -168,6 +174,12 @@ public function generateFromReview(TenantReview $review, User $user, array $opti
|
||||
'review_status' => (string) $review->status,
|
||||
'review_completeness_state' => (string) $review->completeness_state,
|
||||
'section_count' => $review->sections->count(),
|
||||
'finding_outcomes' => is_array($review->summary['finding_outcomes'] ?? null)
|
||||
? $review->summary['finding_outcomes']
|
||||
: [],
|
||||
'finding_report_buckets' => is_array($review->summary['finding_report_buckets'] ?? null)
|
||||
? $review->summary['finding_report_buckets']
|
||||
: [],
|
||||
'evidence_resolution' => [
|
||||
'outcome' => 'resolved',
|
||||
'snapshot_id' => (int) $snapshot->getKey(),
|
||||
|
||||
@ -59,6 +59,12 @@ public function compose(EvidenceSnapshot $snapshot, ?TenantReview $review = null
|
||||
'publish_blockers' => $blockers,
|
||||
'has_ready_export' => false,
|
||||
'finding_count' => (int) data_get($sections, '0.summary_payload.finding_count', 0),
|
||||
'finding_outcomes' => is_array(data_get($sections, '0.summary_payload.finding_outcomes'))
|
||||
? data_get($sections, '0.summary_payload.finding_outcomes')
|
||||
: [],
|
||||
'finding_report_buckets' => is_array(data_get($sections, '0.summary_payload.finding_report_buckets'))
|
||||
? data_get($sections, '0.summary_payload.finding_report_buckets')
|
||||
: [],
|
||||
'report_count' => 2,
|
||||
'operation_count' => (int) data_get($sections, '5.summary_payload.operation_count', 0),
|
||||
'highlights' => data_get($sections, '0.render_payload.highlights', []),
|
||||
|
||||
@ -6,12 +6,17 @@
|
||||
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\EvidenceSnapshotItem;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\TenantReviewCompletenessState;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
final class TenantReviewSectionFactory
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
@ -47,6 +52,8 @@ private function executiveSummarySection(
|
||||
$rolesSummary = $this->summary($rolesItem);
|
||||
$baselineSummary = $this->summary($baselineItem);
|
||||
$operationsSummary = $this->summary($operationsItem);
|
||||
$findingOutcomes = is_array($findingsSummary['outcome_counts'] ?? null) ? $findingsSummary['outcome_counts'] : [];
|
||||
$findingReportBuckets = is_array($findingsSummary['report_bucket_counts'] ?? null) ? $findingsSummary['report_bucket_counts'] : [];
|
||||
$riskAcceptance = is_array($findingsSummary['risk_acceptance'] ?? null) ? $findingsSummary['risk_acceptance'] : [];
|
||||
|
||||
$openCount = (int) ($findingsSummary['open_count'] ?? 0);
|
||||
@ -55,9 +62,11 @@ private function executiveSummarySection(
|
||||
$postureScore = $permissionSummary['posture_score'] ?? null;
|
||||
$operationFailures = (int) ($operationsSummary['failed_count'] ?? 0);
|
||||
$partialOperations = (int) ($operationsSummary['partial_count'] ?? 0);
|
||||
$outcomeSummary = $this->findingOutcomeSemantics->compactOutcomeSummary($findingOutcomes);
|
||||
|
||||
$highlights = array_values(array_filter([
|
||||
sprintf('%d open risks from %d tracked findings.', $openCount, $findingCount),
|
||||
$outcomeSummary !== null ? 'Terminal outcomes: '.$outcomeSummary.'.' : null,
|
||||
$postureScore !== null ? sprintf('Permission posture score is %s.', $postureScore) : 'Permission posture report is unavailable.',
|
||||
sprintf('%d baseline drift findings remain open.', $driftCount),
|
||||
sprintf('%d recent operations failed and %d completed with warnings.', $operationFailures, $partialOperations),
|
||||
@ -81,6 +90,8 @@ private function executiveSummarySection(
|
||||
'summary_payload' => [
|
||||
'finding_count' => $findingCount,
|
||||
'open_risk_count' => $openCount,
|
||||
'finding_outcomes' => $findingOutcomes,
|
||||
'finding_report_buckets' => $findingReportBuckets,
|
||||
'posture_score' => $postureScore,
|
||||
'baseline_drift_count' => $driftCount,
|
||||
'failed_operation_count' => $operationFailures,
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Baselines\BaselineProfileStatus;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\RestoreRunStatus;
|
||||
@ -142,6 +143,22 @@ public static function findingWorkflowFamilies(): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function findingTerminalOutcomes(): array
|
||||
{
|
||||
return app(FindingOutcomeSemantics::class)->terminalOutcomeOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function findingVerificationStates(): array
|
||||
{
|
||||
return app(FindingOutcomeSemantics::class)->verificationStateOptions();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
|
||||
203
apps/platform/app/Support/Findings/FindingOutcomeSemantics.php
Normal file
203
apps/platform/app/Support/Findings/FindingOutcomeSemantics.php
Normal file
@ -0,0 +1,203 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Findings;
|
||||
|
||||
use App\Models\Finding;
|
||||
|
||||
final class FindingOutcomeSemantics
|
||||
{
|
||||
public const string VERIFICATION_PENDING = 'pending_verification';
|
||||
|
||||
public const string VERIFICATION_VERIFIED = 'verified_cleared';
|
||||
|
||||
public const string VERIFICATION_NOT_APPLICABLE = 'not_applicable';
|
||||
|
||||
public const string OUTCOME_RESOLVED_PENDING_VERIFICATION = 'resolved_pending_verification';
|
||||
|
||||
public const string OUTCOME_VERIFIED_CLEARED = 'verified_cleared';
|
||||
|
||||
public const string OUTCOME_CLOSED_FALSE_POSITIVE = 'closed_false_positive';
|
||||
|
||||
public const string OUTCOME_CLOSED_DUPLICATE = 'closed_duplicate';
|
||||
|
||||
public const string OUTCOME_CLOSED_NO_LONGER_APPLICABLE = 'closed_no_longer_applicable';
|
||||
|
||||
public const string OUTCOME_RISK_ACCEPTED = 'risk_accepted';
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* terminal_outcome_key: ?string,
|
||||
* label: ?string,
|
||||
* verification_state: string,
|
||||
* verification_label: ?string,
|
||||
* report_bucket: ?string
|
||||
* }
|
||||
*/
|
||||
public function describe(Finding $finding): array
|
||||
{
|
||||
$terminalOutcomeKey = $this->terminalOutcomeKey($finding);
|
||||
$verificationState = $this->verificationState($finding);
|
||||
|
||||
return [
|
||||
'terminal_outcome_key' => $terminalOutcomeKey,
|
||||
'label' => $terminalOutcomeKey !== null ? $this->terminalOutcomeLabel($terminalOutcomeKey) : null,
|
||||
'verification_state' => $verificationState,
|
||||
'verification_label' => $verificationState !== self::VERIFICATION_NOT_APPLICABLE
|
||||
? $this->verificationStateLabel($verificationState)
|
||||
: null,
|
||||
'report_bucket' => $terminalOutcomeKey !== null ? $this->reportBucket($terminalOutcomeKey) : null,
|
||||
];
|
||||
}
|
||||
|
||||
public function terminalOutcomeKey(Finding $finding): ?string
|
||||
{
|
||||
return match ((string) $finding->status) {
|
||||
Finding::STATUS_RESOLVED => $this->resolvedTerminalOutcomeKey((string) ($finding->resolved_reason ?? '')),
|
||||
Finding::STATUS_CLOSED => $this->closedTerminalOutcomeKey((string) ($finding->closed_reason ?? '')),
|
||||
Finding::STATUS_RISK_ACCEPTED => self::OUTCOME_RISK_ACCEPTED,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public function verificationState(Finding $finding): string
|
||||
{
|
||||
if ((string) $finding->status !== Finding::STATUS_RESOLVED) {
|
||||
return self::VERIFICATION_NOT_APPLICABLE;
|
||||
}
|
||||
|
||||
$reason = (string) ($finding->resolved_reason ?? '');
|
||||
|
||||
if (Finding::isSystemResolveReason($reason)) {
|
||||
return self::VERIFICATION_VERIFIED;
|
||||
}
|
||||
|
||||
if (Finding::isManualResolveReason($reason)) {
|
||||
return self::VERIFICATION_PENDING;
|
||||
}
|
||||
|
||||
return self::VERIFICATION_NOT_APPLICABLE;
|
||||
}
|
||||
|
||||
public function systemReopenReasonFor(Finding $finding): string
|
||||
{
|
||||
return $this->verificationState($finding) === self::VERIFICATION_PENDING
|
||||
? Finding::REOPEN_REASON_VERIFICATION_FAILED
|
||||
: Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function terminalOutcomeOptions(): array
|
||||
{
|
||||
return [
|
||||
self::OUTCOME_RESOLVED_PENDING_VERIFICATION => $this->terminalOutcomeLabel(self::OUTCOME_RESOLVED_PENDING_VERIFICATION),
|
||||
self::OUTCOME_VERIFIED_CLEARED => $this->terminalOutcomeLabel(self::OUTCOME_VERIFIED_CLEARED),
|
||||
self::OUTCOME_CLOSED_FALSE_POSITIVE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_FALSE_POSITIVE),
|
||||
self::OUTCOME_CLOSED_DUPLICATE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_DUPLICATE),
|
||||
self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => $this->terminalOutcomeLabel(self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE),
|
||||
self::OUTCOME_RISK_ACCEPTED => $this->terminalOutcomeLabel(self::OUTCOME_RISK_ACCEPTED),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function verificationStateOptions(): array
|
||||
{
|
||||
return [
|
||||
self::VERIFICATION_PENDING => $this->verificationStateLabel(self::VERIFICATION_PENDING),
|
||||
self::VERIFICATION_VERIFIED => $this->verificationStateLabel(self::VERIFICATION_VERIFIED),
|
||||
self::VERIFICATION_NOT_APPLICABLE => $this->verificationStateLabel(self::VERIFICATION_NOT_APPLICABLE),
|
||||
];
|
||||
}
|
||||
|
||||
public function terminalOutcomeLabel(string $terminalOutcomeKey): string
|
||||
{
|
||||
return match ($terminalOutcomeKey) {
|
||||
self::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'Resolved pending verification',
|
||||
self::OUTCOME_VERIFIED_CLEARED => 'Verified cleared',
|
||||
self::OUTCOME_CLOSED_FALSE_POSITIVE => 'Closed as false positive',
|
||||
self::OUTCOME_CLOSED_DUPLICATE => 'Closed as duplicate',
|
||||
self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE => 'Closed as no longer applicable',
|
||||
self::OUTCOME_RISK_ACCEPTED => 'Risk accepted',
|
||||
default => 'Unknown outcome',
|
||||
};
|
||||
}
|
||||
|
||||
public function verificationStateLabel(string $verificationState): string
|
||||
{
|
||||
return match ($verificationState) {
|
||||
self::VERIFICATION_PENDING => 'Pending verification',
|
||||
self::VERIFICATION_VERIFIED => 'Verified cleared',
|
||||
default => 'Not applicable',
|
||||
};
|
||||
}
|
||||
|
||||
public function reportBucket(string $terminalOutcomeKey): string
|
||||
{
|
||||
return match ($terminalOutcomeKey) {
|
||||
self::OUTCOME_RESOLVED_PENDING_VERIFICATION => 'remediation_pending_verification',
|
||||
self::OUTCOME_VERIFIED_CLEARED => 'remediation_verified',
|
||||
self::OUTCOME_RISK_ACCEPTED => 'accepted_risk',
|
||||
default => 'administrative_closure',
|
||||
};
|
||||
}
|
||||
|
||||
public function compactOutcomeSummary(array $counts): ?string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
foreach ($this->orderedOutcomeKeys() as $outcomeKey) {
|
||||
$count = (int) ($counts[$outcomeKey] ?? 0);
|
||||
|
||||
if ($count < 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = sprintf('%d %s', $count, strtolower($this->terminalOutcomeLabel($outcomeKey)));
|
||||
}
|
||||
|
||||
return $parts === [] ? null : implode(', ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function orderedOutcomeKeys(): array
|
||||
{
|
||||
return [
|
||||
self::OUTCOME_RESOLVED_PENDING_VERIFICATION,
|
||||
self::OUTCOME_VERIFIED_CLEARED,
|
||||
self::OUTCOME_CLOSED_FALSE_POSITIVE,
|
||||
self::OUTCOME_CLOSED_DUPLICATE,
|
||||
self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE,
|
||||
self::OUTCOME_RISK_ACCEPTED,
|
||||
];
|
||||
}
|
||||
|
||||
private function resolvedTerminalOutcomeKey(string $reason): ?string
|
||||
{
|
||||
if (Finding::isSystemResolveReason($reason)) {
|
||||
return self::OUTCOME_VERIFIED_CLEARED;
|
||||
}
|
||||
|
||||
if (Finding::isManualResolveReason($reason)) {
|
||||
return self::OUTCOME_RESOLVED_PENDING_VERIFICATION;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function closedTerminalOutcomeKey(string $reason): ?string
|
||||
{
|
||||
return match ($reason) {
|
||||
Finding::CLOSE_REASON_FALSE_POSITIVE => self::OUTCOME_CLOSED_FALSE_POSITIVE,
|
||||
Finding::CLOSE_REASON_DUPLICATE => self::OUTCOME_CLOSED_DUPLICATE,
|
||||
Finding::CLOSE_REASON_NO_LONGER_APPLICABLE => self::OUTCOME_CLOSED_NO_LONGER_APPLICABLE,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -76,7 +76,7 @@ public function handle(Request $request, Closure $next): Response
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (in_array($path, ['/admin/findings/my-work', '/admin/findings/intake'], true)) {
|
||||
if (in_array($path, ['/admin/findings/my-work', '/admin/findings/intake', '/admin/findings/hygiene'], true)) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
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/workspaces')
|
||||
|| 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'], 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', '/admin/findings/intake', '/admin/findings/hygiene'], true)
|
||||
) {
|
||||
$this->configureNavigationForRequest($panel);
|
||||
|
||||
@ -265,6 +265,10 @@ private function adminPathRequiresTenantSelection(string $path): bool
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,11 @@
|
||||
|
||||
namespace App\Support\OpsUx;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
@ -12,11 +16,13 @@
|
||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
|
||||
use App\Support\RedactionIntegrity;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use App\Support\Ui\DerivedState\DerivedStateFamily;
|
||||
use App\Support\Ui\DerivedState\DerivedStateKey;
|
||||
use App\Support\Ui\DerivedState\RequestScopedDerivedStateStore;
|
||||
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
|
||||
use App\Support\Ui\OperatorExplanation\OperatorExplanationPattern;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
|
||||
final class OperationUxPresenter
|
||||
@ -81,6 +87,48 @@ public static function scopeBusyToast(
|
||||
->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.
|
||||
*
|
||||
@ -89,44 +137,40 @@ public static function scopeBusyToast(
|
||||
*/
|
||||
public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $tenant = null): FilamentNotification
|
||||
{
|
||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
||||
$presentation = self::terminalPresentation($run);
|
||||
$bodyLines = [$presentation['body']];
|
||||
$payload = self::terminalNotificationPayload($run);
|
||||
$actionUrl = $tenant instanceof Tenant
|
||||
? OperationRunUrl::view($run, $tenant)
|
||||
: OperationRunLinks::tenantlessView($run);
|
||||
|
||||
$failureMessage = self::surfaceFailureDetail($run);
|
||||
if ($failureMessage !== null) {
|
||||
$bodyLines[] = $failureMessage;
|
||||
return self::makeDatabaseNotification(
|
||||
title: $payload['title'],
|
||||
body: $payload['body'],
|
||||
status: $payload['status'],
|
||||
actionName: 'view_run',
|
||||
actionLabel: OperationRunLinks::openLabel(),
|
||||
actionUrl: $actionUrl,
|
||||
supportingLines: $payload['supportingLines'],
|
||||
);
|
||||
}
|
||||
|
||||
$guidance = self::surfaceGuidance($run);
|
||||
if ($guidance !== null) {
|
||||
$bodyLines[] = $guidance;
|
||||
}
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function terminalDatabaseNotificationMessage(OperationRun $run, object $notifiable): array
|
||||
{
|
||||
$payload = self::terminalNotificationPayload($run);
|
||||
$primaryAction = self::operationRunPrimaryAction($run, $notifiable);
|
||||
|
||||
$summary = SummaryCountsNormalizer::renderSummaryLine(is_array($run->summary_counts) ? $run->summary_counts : []);
|
||||
if ($summary !== null) {
|
||||
$bodyLines[] = $summary;
|
||||
}
|
||||
|
||||
$integritySummary = RedactionIntegrity::noteForRun($run);
|
||||
if (is_string($integritySummary) && trim($integritySummary) !== '') {
|
||||
$bodyLines[] = trim($integritySummary);
|
||||
}
|
||||
|
||||
$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;
|
||||
return self::databaseNotificationMessage(
|
||||
title: $payload['title'],
|
||||
body: $payload['body'],
|
||||
status: $payload['status'],
|
||||
actionName: 'view_run',
|
||||
actionLabel: $primaryAction['label'],
|
||||
actionUrl: $primaryAction['url'],
|
||||
actionTarget: $primaryAction['target'],
|
||||
supportingLines: $payload['supportingLines'],
|
||||
);
|
||||
}
|
||||
|
||||
public static function surfaceGuidance(OperationRun $run): ?string
|
||||
@ -345,6 +389,59 @@ 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
|
||||
{
|
||||
return self::resolveGovernanceOperatorExplanation($run);
|
||||
@ -377,7 +474,7 @@ private static function terminalPresentation(OperationRun $run): array
|
||||
if ($freshnessState->isReconciledFailed()) {
|
||||
return [
|
||||
'titleSuffix' => 'was automatically reconciled',
|
||||
'body' => $reasonEnvelope?->operatorLabel ?? 'Automatically reconciled after infrastructure failure.',
|
||||
'body' => 'Automatically reconciled after infrastructure failure.',
|
||||
'status' => 'danger',
|
||||
];
|
||||
}
|
||||
@ -395,17 +492,198 @@ private static function terminalPresentation(OperationRun $run): array
|
||||
],
|
||||
'blocked' => [
|
||||
'titleSuffix' => 'blocked by prerequisite',
|
||||
'body' => $reasonEnvelope?->operatorLabel ?? 'Blocked by prerequisite.',
|
||||
'body' => 'Blocked by prerequisite.',
|
||||
'status' => 'warning',
|
||||
],
|
||||
default => [
|
||||
'titleSuffix' => 'execution failed',
|
||||
'body' => $reasonEnvelope?->operatorLabel ?? 'Execution failed.',
|
||||
'body' => 'Execution failed.',
|
||||
'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
|
||||
{
|
||||
if (self::firstNextStepLabel($run) !== null) {
|
||||
|
||||
@ -70,7 +70,7 @@ public static function families(): array
|
||||
'canonicalObject' => 'finding',
|
||||
'panels' => ['tenant'],
|
||||
'surfaceKeys' => ['view_finding', 'finding_list_row', 'finding_bulk'],
|
||||
'defaultActionOrder' => ['close_finding', 'reopen_finding'],
|
||||
'defaultActionOrder' => ['resolve_finding', 'close_finding', 'reopen_finding'],
|
||||
'supportsDocumentedDeviation' => false,
|
||||
'defaultMutationScopeSource' => 'finding lifecycle',
|
||||
],
|
||||
@ -260,6 +260,20 @@ public static function rules(): array
|
||||
serviceOwner: 'OperationRunTriageService',
|
||||
surfaceKeys: ['system_view_run'],
|
||||
),
|
||||
'resolve_finding' => new GovernanceActionRule(
|
||||
actionKey: 'resolve_finding',
|
||||
familyKey: 'finding_lifecycle',
|
||||
frictionClass: GovernanceFrictionClass::F2,
|
||||
reasonPolicy: GovernanceReasonPolicy::Required,
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Resolve',
|
||||
modalHeading: 'Resolve finding',
|
||||
modalDescription: 'Resolve this finding for the current tenant. TenantPilot records a canonical remediation outcome and keeps the finding in a pending-verification state until trusted evidence later confirms it is actually clear.',
|
||||
successTitle: 'Finding resolved pending verification',
|
||||
auditVerb: 'resolve finding',
|
||||
serviceOwner: 'FindingWorkflowService',
|
||||
surfaceKeys: ['view_finding', 'finding_list_row', 'finding_bulk'],
|
||||
),
|
||||
'close_finding' => new GovernanceActionRule(
|
||||
actionKey: 'close_finding',
|
||||
familyKey: 'finding_lifecycle',
|
||||
@ -268,7 +282,7 @@ public static function rules(): array
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Close',
|
||||
modalHeading: 'Close finding',
|
||||
modalDescription: 'Close this finding for the current tenant. TenantPilot records the closing rationale and closes the finding lifecycle.',
|
||||
modalDescription: 'Close this finding for the current tenant. TenantPilot records a canonical administrative closure reason such as false positive, duplicate, or no longer applicable.',
|
||||
successTitle: 'Finding closed',
|
||||
auditVerb: 'close finding',
|
||||
serviceOwner: 'FindingWorkflowService',
|
||||
@ -282,7 +296,7 @@ public static function rules(): array
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Reopen',
|
||||
modalHeading: 'Reopen finding',
|
||||
modalDescription: 'Reopen this closed finding for the current tenant. TenantPilot records why the lifecycle is being reopened and recalculates due attention.',
|
||||
modalDescription: 'Reopen this terminal finding for the current tenant. TenantPilot records a canonical reopen reason and recalculates due attention.',
|
||||
successTitle: 'Finding reopened',
|
||||
auditVerb: 'reopen finding',
|
||||
serviceOwner: 'FindingWorkflowService',
|
||||
@ -489,6 +503,17 @@ public static function surfaceBindings(): array
|
||||
'uiFieldKey' => 'reason',
|
||||
'auditChannel' => 'system_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'view_finding',
|
||||
'pageClass' => 'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding',
|
||||
'actionName' => 'resolve',
|
||||
'familyKey' => 'finding_lifecycle',
|
||||
'statePredicate' => 'finding has open status',
|
||||
'primaryOrSecondary' => 'primary',
|
||||
'capabilityKey' => 'tenant_findings.resolve',
|
||||
'uiFieldKey' => 'resolved_reason',
|
||||
'auditChannel' => 'tenant_audit',
|
||||
],
|
||||
[
|
||||
'surfaceKey' => 'view_finding',
|
||||
'pageClass' => 'App\\Filament\\Resources\\FindingResource\\Pages\\ViewFinding',
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
use App\Filament\Pages\BaselineCompareLanding;
|
||||
use App\Filament\Pages\ChooseTenant;
|
||||
use App\Filament\Pages\Findings\FindingsHygieneReport;
|
||||
use App\Filament\Pages\Findings\MyFindingsInbox;
|
||||
use App\Filament\Pages\TenantDashboard;
|
||||
use App\Filament\Resources\FindingResource;
|
||||
@ -20,6 +21,7 @@
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Findings\FindingAssignmentHygieneService;
|
||||
use App\Services\Auth\WorkspaceRoleCapabilityMap;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\BackupHealth\TenantBackupHealthAssessment;
|
||||
@ -49,6 +51,7 @@ final class WorkspaceOverviewBuilder
|
||||
public function __construct(
|
||||
private WorkspaceCapabilityResolver $workspaceCapabilityResolver,
|
||||
private CapabilityResolver $capabilityResolver,
|
||||
private FindingAssignmentHygieneService $findingAssignmentHygieneService,
|
||||
private TenantGovernanceAggregateResolver $tenantGovernanceAggregateResolver,
|
||||
private TenantBackupHealthResolver $tenantBackupHealthResolver,
|
||||
private RestoreSafetyResolver $restoreSafetyResolver,
|
||||
@ -134,6 +137,7 @@ public function build(Workspace $workspace, User $user): array
|
||||
];
|
||||
|
||||
$myFindingsSignal = $this->myFindingsSignal($workspaceId, $accessibleTenants, $user);
|
||||
$findingsHygieneSignal = $this->findingsHygieneSignal($workspace, $user);
|
||||
|
||||
$zeroTenantState = null;
|
||||
|
||||
@ -174,6 +178,7 @@ public function build(Workspace $workspace, User $user): array
|
||||
'workspace_name' => (string) $workspace->name,
|
||||
'accessible_tenant_count' => $accessibleTenants->count(),
|
||||
'my_findings_signal' => $myFindingsSignal,
|
||||
'findings_hygiene_signal' => $findingsHygieneSignal,
|
||||
'summary_metrics' => $summaryMetrics,
|
||||
'triage_review_progress' => $triageReviewProgress['families'],
|
||||
'attention_items' => $attentionItems,
|
||||
@ -263,6 +268,66 @@ 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
|
||||
* @return list<array<string, mixed>>
|
||||
|
||||
@ -120,7 +120,16 @@ public function resolved(): static
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => now(),
|
||||
'resolved_reason' => 'permission_granted',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
]);
|
||||
}
|
||||
|
||||
public function verifiedCleared(string $reason = Finding::RESOLVE_REASON_NO_LONGER_DRIFTING): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => now(),
|
||||
'resolved_reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -140,6 +149,34 @@ 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.
|
||||
*/
|
||||
@ -148,7 +185,7 @@ public function closed(): static
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'closed_at' => now(),
|
||||
'closed_reason' => 'duplicate',
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -160,7 +197,7 @@ public function riskAccepted(): static
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||
'closed_at' => now(),
|
||||
'closed_reason' => 'accepted_risk',
|
||||
'closed_reason' => Finding::CLOSE_REASON_ACCEPTED_RISK,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,103 @@
|
||||
<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>
|
||||
@ -3,6 +3,7 @@
|
||||
$workspace = $overview['workspace'] ?? ['name' => 'Workspace'];
|
||||
$quickActions = $overview['quick_actions'] ?? [];
|
||||
$myFindingsSignal = $overview['my_findings_signal'] ?? null;
|
||||
$findingsHygieneSignal = $overview['findings_hygiene_signal'] ?? null;
|
||||
$zeroTenantState = $overview['zero_tenant_state'] ?? null;
|
||||
@endphp
|
||||
|
||||
@ -101,6 +102,52 @@ class="rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition
|
||||
</section>
|
||||
@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))
|
||||
<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">
|
||||
|
||||
@ -0,0 +1,284 @@
|
||||
<?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,3 +194,41 @@ function invokeSlaDueEvents(int $workspaceId, CarbonImmutable $windowStart): arr
|
||||
->and($first[0]['fingerprint_key'])->toBe($second[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,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
@ -1100,7 +1100,7 @@
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => now()->subMinute(),
|
||||
'resolved_reason' => 'manually_resolved',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
])->save();
|
||||
|
||||
$firstRun->update(['completed_at' => now()->subMinute()]);
|
||||
|
||||
@ -63,3 +63,52 @@
|
||||
->assertSee('Baseline compare')
|
||||
->assertSee('Operation #'.$run->getKey());
|
||||
});
|
||||
|
||||
it('shows canonical manual terminal outcome and verification labels on finding detail', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
'resolved_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$this->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Terminal outcome')
|
||||
->assertSee('Resolved pending verification')
|
||||
->assertSee('Verification')
|
||||
->assertSee('Pending verification')
|
||||
->assertSee('Resolved reason')
|
||||
->assertSee('Remediated');
|
||||
});
|
||||
|
||||
it('shows verified clear and administrative closure labels on finding detail', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$verifiedFinding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING,
|
||||
'resolved_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$closedFinding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
'closed_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$this->get(FindingResource::getUrl('view', ['record' => $verifiedFinding], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Verified cleared')
|
||||
->assertSee('No longer drifting');
|
||||
|
||||
$this->get(FindingResource::getUrl('view', ['record' => $closedFinding], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Closed as duplicate')
|
||||
->assertSee('Duplicate');
|
||||
});
|
||||
|
||||
@ -20,9 +20,9 @@
|
||||
|
||||
$service->triage($finding, $tenant, $user);
|
||||
$service->assign($finding->refresh(), $tenant, $user, null, (int) $user->getKey());
|
||||
$service->resolve($finding->refresh(), $tenant, $user, 'patched');
|
||||
$service->reopen($finding->refresh(), $tenant, $user, 'The issue recurred after validation.');
|
||||
$service->close($finding->refresh(), $tenant, $user, 'duplicate');
|
||||
$service->resolve($finding->refresh(), $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
|
||||
$service->reopen($finding->refresh(), $tenant, $user, Finding::REOPEN_REASON_MANUAL_REASSESSMENT);
|
||||
$service->close($finding->refresh(), $tenant, $user, Finding::CLOSE_REASON_DUPLICATE);
|
||||
|
||||
expect(AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
@ -37,14 +37,14 @@
|
||||
->and($closedAudit->targetDisplayLabel())->toContain('finding')
|
||||
->and(data_get($closedAudit->metadata, 'before_status'))->toBe(Finding::STATUS_REOPENED)
|
||||
->and(data_get($closedAudit->metadata, 'after_status'))->toBe(Finding::STATUS_CLOSED)
|
||||
->and(data_get($closedAudit->metadata, 'closed_reason'))->toBe('duplicate')
|
||||
->and(data_get($closedAudit->metadata, 'closed_reason'))->toBe(Finding::CLOSE_REASON_DUPLICATE)
|
||||
->and(data_get($closedAudit->metadata, 'before.evidence_jsonb'))->toBeNull()
|
||||
->and(data_get($closedAudit->metadata, 'after.evidence_jsonb'))->toBeNull();
|
||||
|
||||
$reopenedAudit = $this->latestFindingAudit($finding, AuditActionId::FindingReopened);
|
||||
|
||||
expect($reopenedAudit)->not->toBeNull()
|
||||
->and(data_get($reopenedAudit->metadata, 'reopened_reason'))->toBe('The issue recurred after validation.');
|
||||
->and(data_get($reopenedAudit->metadata, 'reopened_reason'))->toBe(Finding::REOPEN_REASON_MANUAL_REASSESSMENT);
|
||||
});
|
||||
|
||||
it('deduplicates repeated finding audit writes for the same successful mutation payload', function (): void {
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
use App\Models\Finding;
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@ -24,7 +25,7 @@
|
||||
$service = app(FindingWorkflowService::class);
|
||||
|
||||
$service->triage($finding, $tenant, $user);
|
||||
$service->resolve($finding->refresh(), $tenant, $user, 'fixed');
|
||||
$service->resolve($finding->refresh(), $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
@ -40,7 +41,9 @@
|
||||
->and(data_get($audit->metadata, 'finding_id'))->toBe((int) $finding->getKey())
|
||||
->and(data_get($audit->metadata, 'before_status'))->toBe(Finding::STATUS_TRIAGED)
|
||||
->and(data_get($audit->metadata, 'after_status'))->toBe(Finding::STATUS_RESOLVED)
|
||||
->and(data_get($audit->metadata, 'resolved_reason'))->toBe('fixed')
|
||||
->and(data_get($audit->metadata, 'resolved_reason'))->toBe(Finding::RESOLVE_REASON_REMEDIATED)
|
||||
->and(data_get($audit->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION)
|
||||
->and(data_get($audit->metadata, 'verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_PENDING)
|
||||
->and(data_get($audit->metadata, 'before'))->toBeArray()
|
||||
->and(data_get($audit->metadata, 'after'))->toBeArray()
|
||||
->and(data_get($audit->metadata, 'evidence_jsonb'))->toBeNull()
|
||||
|
||||
@ -120,7 +120,7 @@ function invokeAutomationBaselineCompareUpsertFindings(
|
||||
expect($audit)->not->toBeNull()
|
||||
->and($audit->actorSnapshot()->type)->toBe(AuditActorType::System)
|
||||
->and(data_get($audit->metadata, 'system_origin'))->toBeTrue()
|
||||
->and(data_get($audit->metadata, 'resolved_reason'))->toBe('no_longer_drifting');
|
||||
->and(data_get($audit->metadata, 'resolved_reason'))->toBe(Finding::RESOLVE_REASON_NO_LONGER_DRIFTING);
|
||||
});
|
||||
|
||||
it('writes system-origin audit rows for permission posture auto-resolve and recurrence reopen', function (): void {
|
||||
@ -185,7 +185,7 @@ function invokeAutomationBaselineCompareUpsertFindings(
|
||||
|
||||
expect($resolvedAudit)->not->toBeNull()
|
||||
->and($resolvedAudit->actorSnapshot()->type)->toBe(AuditActorType::System)
|
||||
->and(data_get($resolvedAudit->metadata, 'resolved_reason'))->toBe('permission_granted')
|
||||
->and(data_get($resolvedAudit->metadata, 'resolved_reason'))->toBe(Finding::RESOLVE_REASON_PERMISSION_GRANTED)
|
||||
->and($reopenedAudit)->not->toBeNull()
|
||||
->and($reopenedAudit->actorSnapshot()->type)->toBe(AuditActorType::System)
|
||||
->and(data_get($reopenedAudit->metadata, 'after_status'))->toBe(Finding::STATUS_REOPENED);
|
||||
@ -299,7 +299,7 @@ function invokeAutomationBaselineCompareUpsertFindings(
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => CarbonImmutable::parse('2026-03-18T09:00:00Z'),
|
||||
'resolved_reason' => 'fixed',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
])->save();
|
||||
|
||||
$run2 = OperationRun::factory()->create([
|
||||
|
||||
@ -91,7 +91,7 @@
|
||||
'subject_external_id' => 'policy-dupe',
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => CarbonImmutable::parse('2026-02-21T00:00:00Z'),
|
||||
'resolved_reason' => 'fixed',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
'recurrence_key' => null,
|
||||
'evidence_jsonb' => $evidence,
|
||||
'first_seen_at' => null,
|
||||
@ -126,9 +126,11 @@
|
||||
->and($open->status)->toBe(Finding::STATUS_NEW);
|
||||
|
||||
expect($duplicate->recurrence_key)->toBeNull()
|
||||
->and($duplicate->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($duplicate->resolved_reason)->toBe('consolidated_duplicate')
|
||||
->and($duplicate->resolved_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00');
|
||||
->and($duplicate->status)->toBe(Finding::STATUS_CLOSED)
|
||||
->and($duplicate->resolved_reason)->toBeNull()
|
||||
->and($duplicate->resolved_at)->toBeNull()
|
||||
->and($duplicate->closed_reason)->toBe(Finding::CLOSE_REASON_DUPLICATE)
|
||||
->and($duplicate->closed_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00');
|
||||
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
@ -88,7 +88,7 @@ function findingBulkAuditResourceIds(int $tenantId, string $action): array
|
||||
|
||||
$component
|
||||
->callTableBulkAction('resolve_selected', $resolveFindings, data: [
|
||||
'resolved_reason' => 'fixed',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
])
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
@ -96,7 +96,7 @@ function findingBulkAuditResourceIds(int $tenantId, string $action): array
|
||||
$finding->refresh();
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->resolved_reason)->toBe('fixed')
|
||||
->and($finding->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED)
|
||||
->and($finding->resolved_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
@ -114,7 +114,7 @@ function findingBulkAuditResourceIds(int $tenantId, string $action): array
|
||||
|
||||
$component
|
||||
->callTableBulkAction('close_selected', $closeFindings, data: [
|
||||
'closed_reason' => 'not applicable',
|
||||
'closed_reason' => Finding::CLOSE_REASON_NO_LONGER_APPLICABLE,
|
||||
])
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
@ -122,7 +122,7 @@ function findingBulkAuditResourceIds(int $tenantId, string $action): array
|
||||
$finding->refresh();
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_CLOSED)
|
||||
->and($finding->closed_reason)->toBe('not applicable')
|
||||
->and($finding->closed_reason)->toBe(Finding::CLOSE_REASON_NO_LONGER_APPLICABLE)
|
||||
->and($finding->closed_at)->not->toBeNull()
|
||||
->and($finding->closed_by_user_id)->not->toBeNull();
|
||||
});
|
||||
|
||||
@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Reviews\ReviewRegister;
|
||||
use App\Filament\Resources\TenantReviewResource;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\Finding;
|
||||
use App\Services\Evidence\EvidenceSnapshotService;
|
||||
use App\Services\Evidence\Sources\FindingsSummarySource;
|
||||
use App\Services\ReviewPackService;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
function seedFindingOutcomeMatrix(\App\Models\Tenant $tenant): array
|
||||
{
|
||||
return [
|
||||
'pending_verification' => Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
]),
|
||||
'verified_cleared' => Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING,
|
||||
]),
|
||||
'closed_duplicate' => Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
]),
|
||||
'risk_accepted' => Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||
'closed_reason' => Finding::CLOSE_REASON_ACCEPTED_RISK,
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
function materializeFindingOutcomeSnapshot(\App\Models\Tenant $tenant): EvidenceSnapshot
|
||||
{
|
||||
$payload = app(EvidenceSnapshotService::class)->buildSnapshotPayload($tenant);
|
||||
|
||||
$snapshot = EvidenceSnapshot::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'fingerprint' => $payload['fingerprint'],
|
||||
'completeness_state' => $payload['completeness'],
|
||||
'summary' => $payload['summary'],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
foreach ($payload['items'] as $item) {
|
||||
$snapshot->items()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'dimension_key' => $item['dimension_key'],
|
||||
'state' => $item['state'],
|
||||
'required' => $item['required'],
|
||||
'source_kind' => $item['source_kind'],
|
||||
'source_record_type' => $item['source_record_type'],
|
||||
'source_record_id' => $item['source_record_id'],
|
||||
'source_fingerprint' => $item['source_fingerprint'],
|
||||
'measured_at' => $item['measured_at'],
|
||||
'freshness_at' => $item['freshness_at'],
|
||||
'summary_payload' => $item['summary_payload'],
|
||||
'sort_order' => $item['sort_order'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $snapshot->load('items');
|
||||
}
|
||||
|
||||
it('summarizes canonical terminal outcomes and report buckets from findings evidence', function (): void {
|
||||
[, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$findings = seedFindingOutcomeMatrix($tenant);
|
||||
|
||||
$summary = app(FindingsSummarySource::class)->collect($tenant)['summary_payload'] ?? [];
|
||||
|
||||
expect(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION))->toBe(1)
|
||||
->and(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED))->toBe(1)
|
||||
->and(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE))->toBe(1)
|
||||
->and(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED))->toBe(1)
|
||||
->and(data_get($summary, 'report_bucket_counts.remediation_pending_verification'))->toBe(1)
|
||||
->and(data_get($summary, 'report_bucket_counts.remediation_verified'))->toBe(1)
|
||||
->and(data_get($summary, 'report_bucket_counts.administrative_closure'))->toBe(1)
|
||||
->and(data_get($summary, 'report_bucket_counts.accepted_risk'))->toBe(1);
|
||||
|
||||
$pendingEntry = collect($summary['entries'] ?? [])->firstWhere('id', (int) $findings['pending_verification']->getKey());
|
||||
$verifiedEntry = collect($summary['entries'] ?? [])->firstWhere('id', (int) $findings['verified_cleared']->getKey());
|
||||
|
||||
expect(data_get($pendingEntry, 'terminal_outcome.key'))->toBe(FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION)
|
||||
->and(data_get($pendingEntry, 'terminal_outcome.verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_PENDING)
|
||||
->and(data_get($verifiedEntry, 'terminal_outcome.key'))->toBe(FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED)
|
||||
->and(data_get($verifiedEntry, 'terminal_outcome.verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_VERIFIED);
|
||||
});
|
||||
|
||||
it('propagates finding outcome summaries into evidence snapshots tenant reviews and review packs', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
seedFindingOutcomeMatrix($tenant);
|
||||
|
||||
$snapshot = materializeFindingOutcomeSnapshot($tenant);
|
||||
|
||||
expect(data_get($snapshot->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION))->toBe(1)
|
||||
->and(data_get($snapshot->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED))->toBe(1)
|
||||
->and(data_get($snapshot->summary, 'finding_report_buckets.accepted_risk'))->toBe(1);
|
||||
|
||||
$review = composeTenantReviewForTest($tenant, $user, $snapshot);
|
||||
|
||||
expect(data_get($review->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE))->toBe(1)
|
||||
->and(data_get($review->summary, 'finding_report_buckets.administrative_closure'))->toBe(1);
|
||||
|
||||
setTenantPanelContext($tenant);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Terminal outcomes:')
|
||||
->assertSee('resolved pending verification')
|
||||
->assertSee('verified cleared')
|
||||
->assertSee('closed as duplicate')
|
||||
->assertSee('risk accepted');
|
||||
|
||||
setAdminPanelContext();
|
||||
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(ReviewRegister::class)
|
||||
->assertCanSeeTableRecords([$review])
|
||||
->assertSee('Terminal outcomes:')
|
||||
->assertSee('resolved pending verification');
|
||||
|
||||
$pack = app(ReviewPackService::class)->generateFromReview($review, $user, [
|
||||
'include_pii' => false,
|
||||
'include_operations' => false,
|
||||
]);
|
||||
|
||||
expect(data_get($pack->summary, 'finding_outcomes.'.FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED))->toBe(1)
|
||||
->and(data_get($pack->summary, 'finding_report_buckets.accepted_risk'))->toBe(1);
|
||||
});
|
||||
@ -8,6 +8,7 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Findings\FindingSlaPolicy;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
@ -213,7 +214,7 @@ function baselineCompareRbacDriftItem(
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'),
|
||||
'resolved_reason' => 'fixed',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
])->save();
|
||||
|
||||
$observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z');
|
||||
@ -259,6 +260,11 @@ function baselineCompareRbacDriftItem(
|
||||
->and($finding->sla_days)->toBe($expectedSlaDays2)
|
||||
->and($finding->due_at?->toIso8601String())->toBe($expectedDueAt2->toIso8601String())
|
||||
->and((int) $finding->current_operation_run_id)->toBe((int) $run2->getKey());
|
||||
|
||||
$reopenedAudit = $this->latestFindingAudit($finding, AuditActionId::FindingReopened);
|
||||
|
||||
expect(data_get($reopenedAudit?->metadata, 'reopened_reason'))
|
||||
->toBe(Finding::REOPEN_REASON_VERIFICATION_FAILED);
|
||||
});
|
||||
|
||||
it('keeps closed baseline compare drift findings terminal on recurrence but updates seen tracking', function (): void {
|
||||
@ -308,7 +314,7 @@ function baselineCompareRbacDriftItem(
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'closed_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'),
|
||||
'closed_reason' => 'accepted',
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
])->save();
|
||||
|
||||
$observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z');
|
||||
@ -396,7 +402,7 @@ function baselineCompareRbacDriftItem(
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => CarbonImmutable::parse('2026-02-26T00:00:00Z'),
|
||||
'resolved_reason' => 'manual',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
])->save();
|
||||
|
||||
$observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z');
|
||||
@ -485,7 +491,7 @@ function baselineCompareRbacDriftItem(
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'),
|
||||
'resolved_reason' => 'fixed',
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING,
|
||||
])->save();
|
||||
|
||||
$observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z');
|
||||
@ -523,4 +529,9 @@ function baselineCompareRbacDriftItem(
|
||||
->and($finding->status)->toBe(Finding::STATUS_REOPENED)
|
||||
->and($finding->times_seen)->toBe(2)
|
||||
->and((string) data_get($finding->evidence_jsonb, 'rbac_role_definition.diff_fingerprint'))->toBe('rbac-diff-b');
|
||||
|
||||
$reopenedAudit = $this->latestFindingAudit($finding, AuditActionId::FindingReopened);
|
||||
|
||||
expect(data_get($reopenedAudit?->metadata, 'reopened_reason'))
|
||||
->toBe(Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user