Compare commits
2 Commits
dev
...
225-assign
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f95854edbc | ||
|
|
bc07e05659 |
@ -1,53 +0,0 @@
|
||||
---
|
||||
name: speckit-git-commit
|
||||
description: Auto-commit changes after a Spec Kit command completes
|
||||
compatibility: Requires spec-kit project structure with .specify/ directory
|
||||
metadata:
|
||||
author: github-spec-kit
|
||||
source: git:commands/speckit.git.commit.md
|
||||
---
|
||||
|
||||
# Auto-Commit Changes
|
||||
|
||||
Automatically stage and commit all changes after a Spec Kit command completes.
|
||||
|
||||
## Behavior
|
||||
|
||||
This command is invoked as a hook after (or before) core commands. It:
|
||||
|
||||
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
|
||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
||||
3. Looks up the specific event key to see if auto-commit is enabled
|
||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
||||
5. Uses the per-command `message` if configured, otherwise a default message
|
||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
||||
|
||||
## Execution
|
||||
|
||||
Determine the event name from the hook that triggered this command, then run the script:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
||||
|
||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
||||
|
||||
## Configuration
|
||||
|
||||
In `.specify/extensions/git/git-config.yml`:
|
||||
|
||||
```yaml
|
||||
auto_commit:
|
||||
default: false # Global toggle — set true to enable for all commands
|
||||
after_specify:
|
||||
enabled: true # Override per-command
|
||||
message: "[Spec Kit] Add specification"
|
||||
after_plan:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add implementation plan"
|
||||
```
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
||||
- If no config file exists: skips (disabled by default)
|
||||
- If no changes to commit: skips with a message
|
||||
@ -1,72 +0,0 @@
|
||||
---
|
||||
name: speckit-git-feature
|
||||
description: Create a feature branch with sequential or timestamp numbering
|
||||
compatibility: Requires spec-kit project structure with .specify/ directory
|
||||
metadata:
|
||||
author: github-spec-kit
|
||||
source: git:commands/speckit.git.feature.md
|
||||
---
|
||||
|
||||
# Create Feature Branch
|
||||
|
||||
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Environment Variable Override
|
||||
|
||||
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
||||
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
||||
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
||||
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||
- If Git is not available, warn the user and skip branch creation
|
||||
|
||||
## Branch Numbering Mode
|
||||
|
||||
Determine the branch numbering strategy by checking configuration in this order:
|
||||
|
||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
||||
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
||||
3. Default to `sequential` if neither exists
|
||||
|
||||
## Execution
|
||||
|
||||
Generate a concise short name (2-4 words) for the branch:
|
||||
- Analyze the feature description and extract the most meaningful keywords
|
||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||
|
||||
Run the appropriate script based on your platform:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
||||
|
||||
**IMPORTANT**:
|
||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
||||
- You must only ever run this script once per feature
|
||||
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed or the current directory is not a Git repository:
|
||||
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
|
||||
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
|
||||
|
||||
## Output
|
||||
|
||||
The script outputs JSON with:
|
||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
||||
@ -1,54 +0,0 @@
|
||||
---
|
||||
name: speckit-git-initialize
|
||||
description: Initialize a Git repository with an initial commit
|
||||
compatibility: Requires spec-kit project structure with .specify/ directory
|
||||
metadata:
|
||||
author: github-spec-kit
|
||||
source: git:commands/speckit.git.initialize.md
|
||||
---
|
||||
|
||||
# Initialize Git Repository
|
||||
|
||||
Initialize a Git repository in the current project directory if one does not already exist.
|
||||
|
||||
## Execution
|
||||
|
||||
Run the appropriate script from the project root:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
|
||||
|
||||
If the extension scripts are not found, fall back to:
|
||||
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
|
||||
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
|
||||
|
||||
The script handles all checks internally:
|
||||
- Skips if Git is not available
|
||||
- Skips if already inside a Git repository
|
||||
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
|
||||
|
||||
## Customization
|
||||
|
||||
Replace the script to add project-specific Git initialization steps:
|
||||
- Custom `.gitignore` templates
|
||||
- Default branch naming (`git config init.defaultBranch`)
|
||||
- Git LFS setup
|
||||
- Git hooks installation
|
||||
- Commit signing configuration
|
||||
- Git Flow initialization
|
||||
|
||||
## Output
|
||||
|
||||
On success:
|
||||
- `✓ Git repository initialized`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed:
|
||||
- Warn the user
|
||||
- Skip repository initialization
|
||||
- The project continues to function without Git (specs can still be created under `specs/`)
|
||||
|
||||
If Git is installed but `git init`, `git add .`, or `git commit` fails:
|
||||
- Surface the error to the user
|
||||
- Stop this command rather than continuing with a partially initialized repository
|
||||
@ -1,50 +0,0 @@
|
||||
---
|
||||
name: speckit-git-remote
|
||||
description: Detect Git remote URL for GitHub integration
|
||||
compatibility: Requires spec-kit project structure with .specify/ directory
|
||||
metadata:
|
||||
author: github-spec-kit
|
||||
source: git:commands/speckit.git.remote.md
|
||||
---
|
||||
|
||||
# Detect Git Remote URL
|
||||
|
||||
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||
- If Git is not available, output a warning and return empty:
|
||||
```
|
||||
[specify] Warning: Git repository not detected; cannot determine remote URL
|
||||
```
|
||||
|
||||
## Execution
|
||||
|
||||
Run the following command to get the remote URL:
|
||||
|
||||
```bash
|
||||
git config --get remote.origin.url
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
Parse the remote URL and determine:
|
||||
|
||||
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
||||
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
||||
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
||||
|
||||
Supported URL formats:
|
||||
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
||||
- SSH: `git@github.com:<owner>/<repo>.git`
|
||||
|
||||
> [!CAUTION]
|
||||
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
||||
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
||||
- Return an empty result
|
||||
- Do NOT error — other workflows should continue without Git remote information
|
||||
@ -1,54 +0,0 @@
|
||||
---
|
||||
name: speckit-git-validate
|
||||
description: Validate current branch follows feature branch naming conventions
|
||||
compatibility: Requires spec-kit project structure with .specify/ directory
|
||||
metadata:
|
||||
author: github-spec-kit
|
||||
source: git:commands/speckit.git.validate.md
|
||||
---
|
||||
|
||||
# Validate Feature Branch
|
||||
|
||||
Validate that the current Git branch follows the expected feature branch naming conventions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||
- If Git is not available, output a warning and skip validation:
|
||||
```
|
||||
[specify] Warning: Git repository not detected; skipped branch validation
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
Get the current branch name:
|
||||
|
||||
```bash
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
```
|
||||
|
||||
The branch name must match one of these patterns:
|
||||
|
||||
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
|
||||
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
||||
|
||||
## Execution
|
||||
|
||||
If on a feature branch (matches either pattern):
|
||||
- Output: `✓ On feature branch: <branch-name>`
|
||||
- Check if the corresponding spec directory exists under `specs/`:
|
||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
|
||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
||||
- If spec directory exists: `✓ Spec directory found: <path>`
|
||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
||||
|
||||
If NOT on a feature branch:
|
||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
||||
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed or the directory is not a Git repository:
|
||||
- Check the `SPECIFY_FEATURE` environment variable as a fallback
|
||||
- If set, validate that value against the naming patterns
|
||||
- If not set, skip validation with a warning
|
||||
@ -1,50 +0,0 @@
|
||||
description = "Auto-commit changes after a Spec Kit command completes"
|
||||
|
||||
# Source: git
|
||||
|
||||
prompt = """
|
||||
# Auto-Commit Changes
|
||||
|
||||
Automatically stage and commit all changes after a Spec Kit command completes.
|
||||
|
||||
## Behavior
|
||||
|
||||
This command is invoked as a hook after (or before) core commands. It:
|
||||
|
||||
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
|
||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
||||
3. Looks up the specific event key to see if auto-commit is enabled
|
||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
||||
5. Uses the per-command `message` if configured, otherwise a default message
|
||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
||||
|
||||
## Execution
|
||||
|
||||
Determine the event name from the hook that triggered this command, then run the script:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
||||
|
||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
||||
|
||||
## Configuration
|
||||
|
||||
In `.specify/extensions/git/git-config.yml`:
|
||||
|
||||
```yaml
|
||||
auto_commit:
|
||||
default: false # Global toggle — set true to enable for all commands
|
||||
after_specify:
|
||||
enabled: true # Override per-command
|
||||
message: "[Spec Kit] Add specification"
|
||||
after_plan:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add implementation plan"
|
||||
```
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
||||
- If no config file exists: skips (disabled by default)
|
||||
- If no changes to commit: skips with a message
|
||||
"""
|
||||
@ -1,69 +0,0 @@
|
||||
description = "Create a feature branch with sequential or timestamp numbering"
|
||||
|
||||
# Source: git
|
||||
|
||||
prompt = """
|
||||
# Create Feature Branch
|
||||
|
||||
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
{{args}}
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Environment Variable Override
|
||||
|
||||
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
||||
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
||||
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
||||
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||
- If Git is not available, warn the user and skip branch creation
|
||||
|
||||
## Branch Numbering Mode
|
||||
|
||||
Determine the branch numbering strategy by checking configuration in this order:
|
||||
|
||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
||||
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
||||
3. Default to `sequential` if neither exists
|
||||
|
||||
## Execution
|
||||
|
||||
Generate a concise short name (2-4 words) for the branch:
|
||||
- Analyze the feature description and extract the most meaningful keywords
|
||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||
|
||||
Run the appropriate script based on your platform:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
||||
|
||||
**IMPORTANT**:
|
||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
||||
- You must only ever run this script once per feature
|
||||
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed or the current directory is not a Git repository:
|
||||
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
|
||||
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
|
||||
|
||||
## Output
|
||||
|
||||
The script outputs JSON with:
|
||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
||||
"""
|
||||
@ -1,51 +0,0 @@
|
||||
description = "Initialize a Git repository with an initial commit"
|
||||
|
||||
# Source: git
|
||||
|
||||
prompt = """
|
||||
# Initialize Git Repository
|
||||
|
||||
Initialize a Git repository in the current project directory if one does not already exist.
|
||||
|
||||
## Execution
|
||||
|
||||
Run the appropriate script from the project root:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
|
||||
|
||||
If the extension scripts are not found, fall back to:
|
||||
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
|
||||
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
|
||||
|
||||
The script handles all checks internally:
|
||||
- Skips if Git is not available
|
||||
- Skips if already inside a Git repository
|
||||
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
|
||||
|
||||
## Customization
|
||||
|
||||
Replace the script to add project-specific Git initialization steps:
|
||||
- Custom `.gitignore` templates
|
||||
- Default branch naming (`git config init.defaultBranch`)
|
||||
- Git LFS setup
|
||||
- Git hooks installation
|
||||
- Commit signing configuration
|
||||
- Git Flow initialization
|
||||
|
||||
## Output
|
||||
|
||||
On success:
|
||||
- `✓ Git repository initialized`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed:
|
||||
- Warn the user
|
||||
- Skip repository initialization
|
||||
- The project continues to function without Git (specs can still be created under `specs/`)
|
||||
|
||||
If Git is installed but `git init`, `git add .`, or `git commit` fails:
|
||||
- Surface the error to the user
|
||||
- Stop this command rather than continuing with a partially initialized repository
|
||||
"""
|
||||
@ -1,47 +0,0 @@
|
||||
description = "Detect Git remote URL for GitHub integration"
|
||||
|
||||
# Source: git
|
||||
|
||||
prompt = """
|
||||
# Detect Git Remote URL
|
||||
|
||||
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||
- If Git is not available, output a warning and return empty:
|
||||
```
|
||||
[specify] Warning: Git repository not detected; cannot determine remote URL
|
||||
```
|
||||
|
||||
## Execution
|
||||
|
||||
Run the following command to get the remote URL:
|
||||
|
||||
```bash
|
||||
git config --get remote.origin.url
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
Parse the remote URL and determine:
|
||||
|
||||
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
||||
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
||||
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
||||
|
||||
Supported URL formats:
|
||||
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
||||
- SSH: `git@github.com:<owner>/<repo>.git`
|
||||
|
||||
> [!CAUTION]
|
||||
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
||||
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
||||
- Return an empty result
|
||||
- Do NOT error — other workflows should continue without Git remote information
|
||||
"""
|
||||
@ -1,51 +0,0 @@
|
||||
description = "Validate current branch follows feature branch naming conventions"
|
||||
|
||||
# Source: git
|
||||
|
||||
prompt = """
|
||||
# Validate Feature Branch
|
||||
|
||||
Validate that the current Git branch follows the expected feature branch naming conventions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||
- If Git is not available, output a warning and skip validation:
|
||||
```
|
||||
[specify] Warning: Git repository not detected; skipped branch validation
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
Get the current branch name:
|
||||
|
||||
```bash
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
```
|
||||
|
||||
The branch name must match one of these patterns:
|
||||
|
||||
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
|
||||
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
||||
|
||||
## Execution
|
||||
|
||||
If on a feature branch (matches either pattern):
|
||||
- Output: `✓ On feature branch: <branch-name>`
|
||||
- Check if the corresponding spec directory exists under `specs/`:
|
||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
|
||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
||||
- If spec directory exists: `✓ Spec directory found: <path>`
|
||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
||||
|
||||
If NOT on a feature branch:
|
||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
||||
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed or the directory is not a Git repository:
|
||||
- Check the `SPECIFY_FEATURE` environment variable as a fallback
|
||||
- If set, validate that value against the naming patterns
|
||||
- If not set, skip validation with a warning
|
||||
"""
|
||||
5
.github/agents/copilot-instructions.md
vendored
5
.github/agents/copilot-instructions.md
vendored
@ -238,8 +238,6 @@ ## Active Technologies
|
||||
- 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)
|
||||
|
||||
@ -274,9 +272,10 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 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
|
||||
- 224-findings-notifications-escalation: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade + Laravel notifications (`database` channel), Filament database notifications, `Finding`, `FindingWorkflowService`, `FindingSlaPolicy`, `AlertRule`, `AlertDelivery`, `AlertDispatchService`, `EvaluateAlertsJob`, `CapabilityResolver`, `WorkspaceContext`, `TenantMembership`, `FindingResource`
|
||||
- 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`
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
|
||||
### Pre-production compatibility check
|
||||
|
||||
51
.github/agents/speckit.git.commit.agent.md
vendored
51
.github/agents/speckit.git.commit.agent.md
vendored
@ -1,51 +0,0 @@
|
||||
---
|
||||
description: Auto-commit changes after a Spec Kit command completes
|
||||
---
|
||||
|
||||
|
||||
<!-- Extension: git -->
|
||||
<!-- Config: .specify/extensions/git/ -->
|
||||
# Auto-Commit Changes
|
||||
|
||||
Automatically stage and commit all changes after a Spec Kit command completes.
|
||||
|
||||
## Behavior
|
||||
|
||||
This command is invoked as a hook after (or before) core commands. It:
|
||||
|
||||
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
|
||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
||||
3. Looks up the specific event key to see if auto-commit is enabled
|
||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
||||
5. Uses the per-command `message` if configured, otherwise a default message
|
||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
||||
|
||||
## Execution
|
||||
|
||||
Determine the event name from the hook that triggered this command, then run the script:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
||||
|
||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
||||
|
||||
## Configuration
|
||||
|
||||
In `.specify/extensions/git/git-config.yml`:
|
||||
|
||||
```yaml
|
||||
auto_commit:
|
||||
default: false # Global toggle — set true to enable for all commands
|
||||
after_specify:
|
||||
enabled: true # Override per-command
|
||||
message: "[Spec Kit] Add specification"
|
||||
after_plan:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add implementation plan"
|
||||
```
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
||||
- If no config file exists: skips (disabled by default)
|
||||
- If no changes to commit: skips with a message
|
||||
70
.github/agents/speckit.git.feature.agent.md
vendored
70
.github/agents/speckit.git.feature.agent.md
vendored
@ -1,70 +0,0 @@
|
||||
---
|
||||
description: Create a feature branch with sequential or timestamp numbering
|
||||
---
|
||||
|
||||
|
||||
<!-- Extension: git -->
|
||||
<!-- Config: .specify/extensions/git/ -->
|
||||
# Create Feature Branch
|
||||
|
||||
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Environment Variable Override
|
||||
|
||||
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
||||
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
||||
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
||||
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||
- If Git is not available, warn the user and skip branch creation
|
||||
|
||||
## Branch Numbering Mode
|
||||
|
||||
Determine the branch numbering strategy by checking configuration in this order:
|
||||
|
||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
||||
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
||||
3. Default to `sequential` if neither exists
|
||||
|
||||
## Execution
|
||||
|
||||
Generate a concise short name (2-4 words) for the branch:
|
||||
- Analyze the feature description and extract the most meaningful keywords
|
||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||
|
||||
Run the appropriate script based on your platform:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
||||
|
||||
**IMPORTANT**:
|
||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
||||
- You must only ever run this script once per feature
|
||||
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed or the current directory is not a Git repository:
|
||||
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
|
||||
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
|
||||
|
||||
## Output
|
||||
|
||||
The script outputs JSON with:
|
||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
||||
52
.github/agents/speckit.git.initialize.agent.md
vendored
52
.github/agents/speckit.git.initialize.agent.md
vendored
@ -1,52 +0,0 @@
|
||||
---
|
||||
description: Initialize a Git repository with an initial commit
|
||||
---
|
||||
|
||||
|
||||
<!-- Extension: git -->
|
||||
<!-- Config: .specify/extensions/git/ -->
|
||||
# Initialize Git Repository
|
||||
|
||||
Initialize a Git repository in the current project directory if one does not already exist.
|
||||
|
||||
## Execution
|
||||
|
||||
Run the appropriate script from the project root:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
|
||||
|
||||
If the extension scripts are not found, fall back to:
|
||||
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
|
||||
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
|
||||
|
||||
The script handles all checks internally:
|
||||
- Skips if Git is not available
|
||||
- Skips if already inside a Git repository
|
||||
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
|
||||
|
||||
## Customization
|
||||
|
||||
Replace the script to add project-specific Git initialization steps:
|
||||
- Custom `.gitignore` templates
|
||||
- Default branch naming (`git config init.defaultBranch`)
|
||||
- Git LFS setup
|
||||
- Git hooks installation
|
||||
- Commit signing configuration
|
||||
- Git Flow initialization
|
||||
|
||||
## Output
|
||||
|
||||
On success:
|
||||
- `✓ Git repository initialized`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed:
|
||||
- Warn the user
|
||||
- Skip repository initialization
|
||||
- The project continues to function without Git (specs can still be created under `specs/`)
|
||||
|
||||
If Git is installed but `git init`, `git add .`, or `git commit` fails:
|
||||
- Surface the error to the user
|
||||
- Stop this command rather than continuing with a partially initialized repository
|
||||
48
.github/agents/speckit.git.remote.agent.md
vendored
48
.github/agents/speckit.git.remote.agent.md
vendored
@ -1,48 +0,0 @@
|
||||
---
|
||||
description: Detect Git remote URL for GitHub integration
|
||||
---
|
||||
|
||||
|
||||
<!-- Extension: git -->
|
||||
<!-- Config: .specify/extensions/git/ -->
|
||||
# Detect Git Remote URL
|
||||
|
||||
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||
- If Git is not available, output a warning and return empty:
|
||||
```
|
||||
[specify] Warning: Git repository not detected; cannot determine remote URL
|
||||
```
|
||||
|
||||
## Execution
|
||||
|
||||
Run the following command to get the remote URL:
|
||||
|
||||
```bash
|
||||
git config --get remote.origin.url
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
Parse the remote URL and determine:
|
||||
|
||||
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
||||
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
||||
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
||||
|
||||
Supported URL formats:
|
||||
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
||||
- SSH: `git@github.com:<owner>/<repo>.git`
|
||||
|
||||
> [!CAUTION]
|
||||
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
||||
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
||||
- Return an empty result
|
||||
- Do NOT error — other workflows should continue without Git remote information
|
||||
52
.github/agents/speckit.git.validate.agent.md
vendored
52
.github/agents/speckit.git.validate.agent.md
vendored
@ -1,52 +0,0 @@
|
||||
---
|
||||
description: Validate current branch follows feature branch naming conventions
|
||||
---
|
||||
|
||||
|
||||
<!-- Extension: git -->
|
||||
<!-- Config: .specify/extensions/git/ -->
|
||||
# Validate Feature Branch
|
||||
|
||||
Validate that the current Git branch follows the expected feature branch naming conventions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||
- If Git is not available, output a warning and skip validation:
|
||||
```
|
||||
[specify] Warning: Git repository not detected; skipped branch validation
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
Get the current branch name:
|
||||
|
||||
```bash
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
```
|
||||
|
||||
The branch name must match one of these patterns:
|
||||
|
||||
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
|
||||
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
||||
|
||||
## Execution
|
||||
|
||||
If on a feature branch (matches either pattern):
|
||||
- Output: `✓ On feature branch: <branch-name>`
|
||||
- Check if the corresponding spec directory exists under `specs/`:
|
||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
|
||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
||||
- If spec directory exists: `✓ Spec directory found: <path>`
|
||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
||||
|
||||
If NOT on a feature branch:
|
||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
||||
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed or the directory is not a Git repository:
|
||||
- Check the `SPECIFY_FEATURE` environment variable as a fallback
|
||||
- If set, validate that value against the naming patterns
|
||||
- If not set, skip validation with a warning
|
||||
5
.github/copilot-instructions.md
vendored
5
.github/copilot-instructions.md
vendored
@ -673,8 +673,3 @@ ### Replaced Utilities
|
||||
| decoration-slice | box-decoration-slice |
|
||||
| decoration-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
3
.github/prompts/speckit.git.commit.prompt.md
vendored
@ -1,3 +0,0 @@
|
||||
---
|
||||
agent: speckit.git.commit
|
||||
---
|
||||
@ -1,3 +0,0 @@
|
||||
---
|
||||
agent: speckit.git.feature
|
||||
---
|
||||
@ -1,3 +0,0 @@
|
||||
---
|
||||
agent: speckit.git.initialize
|
||||
---
|
||||
3
.github/prompts/speckit.git.remote.prompt.md
vendored
3
.github/prompts/speckit.git.remote.prompt.md
vendored
@ -1,3 +0,0 @@
|
||||
---
|
||||
agent: speckit.git.remote
|
||||
---
|
||||
@ -1,3 +0,0 @@
|
||||
---
|
||||
agent: speckit.git.validate
|
||||
---
|
||||
@ -1,148 +0,0 @@
|
||||
installed: []
|
||||
settings:
|
||||
auto_execute_hooks: true
|
||||
hooks:
|
||||
before_constitution:
|
||||
- extension: git
|
||||
command: speckit.git.initialize
|
||||
enabled: true
|
||||
optional: false
|
||||
prompt: Execute speckit.git.initialize?
|
||||
description: Initialize Git repository before constitution setup
|
||||
condition: null
|
||||
before_specify:
|
||||
- extension: git
|
||||
command: speckit.git.feature
|
||||
enabled: true
|
||||
optional: false
|
||||
prompt: Execute speckit.git.feature?
|
||||
description: Create feature branch before specification
|
||||
condition: null
|
||||
before_clarify:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
enabled: true
|
||||
optional: true
|
||||
prompt: Commit outstanding changes before clarification?
|
||||
description: Auto-commit before spec clarification
|
||||
condition: null
|
||||
before_plan:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
enabled: true
|
||||
optional: true
|
||||
prompt: Commit outstanding changes before planning?
|
||||
description: Auto-commit before implementation planning
|
||||
condition: null
|
||||
before_tasks:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
enabled: true
|
||||
optional: true
|
||||
prompt: Commit outstanding changes before task generation?
|
||||
description: Auto-commit before task generation
|
||||
condition: null
|
||||
before_implement:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
enabled: true
|
||||
optional: true
|
||||
prompt: Commit outstanding changes before implementation?
|
||||
description: Auto-commit before implementation
|
||||
condition: null
|
||||
before_checklist:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
enabled: true
|
||||
optional: true
|
||||
prompt: Commit outstanding changes before checklist?
|
||||
description: Auto-commit before checklist generation
|
||||
condition: null
|
||||
before_analyze:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
enabled: true
|
||||
optional: true
|
||||
prompt: Commit outstanding changes before analysis?
|
||||
description: Auto-commit before analysis
|
||||
condition: null
|
||||
before_taskstoissues:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
enabled: true
|
||||
optional: true
|
||||
prompt: Commit outstanding changes before issue sync?
|
||||
description: Auto-commit before tasks-to-issues conversion
|
||||
condition: null
|
||||
after_constitution:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
enabled: true
|
||||
optional: true
|
||||
prompt: Commit constitution changes?
|
||||
description: Auto-commit after constitution update
|
||||
condition: null
|
||||
after_specify:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
enabled: true
|
||||
optional: true
|
||||
prompt: Commit specification changes?
|
||||
description: Auto-commit after specification
|
||||
condition: null
|
||||
after_clarify:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
enabled: true
|
||||
optional: true
|
||||
prompt: Commit clarification changes?
|
||||
description: Auto-commit after spec clarification
|
||||
condition: null
|
||||
after_plan:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
enabled: true
|
||||
optional: true
|
||||
prompt: Commit plan changes?
|
||||
description: Auto-commit after implementation planning
|
||||
condition: null
|
||||
after_tasks:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
enabled: true
|
||||
optional: true
|
||||
prompt: Commit task changes?
|
||||
description: Auto-commit after task generation
|
||||
condition: null
|
||||
after_implement:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
enabled: true
|
||||
optional: true
|
||||
prompt: Commit implementation changes?
|
||||
description: Auto-commit after implementation
|
||||
condition: null
|
||||
after_checklist:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
enabled: true
|
||||
optional: true
|
||||
prompt: Commit checklist changes?
|
||||
description: Auto-commit after checklist generation
|
||||
condition: null
|
||||
after_analyze:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
enabled: true
|
||||
optional: true
|
||||
prompt: Commit analysis results?
|
||||
description: Auto-commit after analysis
|
||||
condition: null
|
||||
after_taskstoissues:
|
||||
- extension: git
|
||||
command: speckit.git.commit
|
||||
enabled: true
|
||||
optional: true
|
||||
prompt: Commit after syncing issues?
|
||||
description: Auto-commit after tasks-to-issues conversion
|
||||
condition: null
|
||||
@ -1,44 +0,0 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"extensions": {
|
||||
"git": {
|
||||
"version": "1.0.0",
|
||||
"source": "local",
|
||||
"manifest_hash": "sha256:9731aa8143a72fbebfdb440f155038ab42642517c2b2bdbbf67c8fdbe076ed79",
|
||||
"enabled": true,
|
||||
"priority": 10,
|
||||
"registered_commands": {
|
||||
"agy": [
|
||||
"speckit.git.feature",
|
||||
"speckit.git.validate",
|
||||
"speckit.git.remote",
|
||||
"speckit.git.initialize",
|
||||
"speckit.git.commit"
|
||||
],
|
||||
"codex": [
|
||||
"speckit.git.feature",
|
||||
"speckit.git.validate",
|
||||
"speckit.git.remote",
|
||||
"speckit.git.initialize",
|
||||
"speckit.git.commit"
|
||||
],
|
||||
"copilot": [
|
||||
"speckit.git.feature",
|
||||
"speckit.git.validate",
|
||||
"speckit.git.remote",
|
||||
"speckit.git.initialize",
|
||||
"speckit.git.commit"
|
||||
],
|
||||
"gemini": [
|
||||
"speckit.git.feature",
|
||||
"speckit.git.validate",
|
||||
"speckit.git.remote",
|
||||
"speckit.git.initialize",
|
||||
"speckit.git.commit"
|
||||
]
|
||||
},
|
||||
"registered_skills": [],
|
||||
"installed_at": "2026-04-22T21:58:03.029565+00:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,100 +0,0 @@
|
||||
# Git Branching Workflow Extension
|
||||
|
||||
Git repository initialization, feature branch creation, numbering (sequential/timestamp), validation, remote detection, and auto-commit for Spec Kit.
|
||||
|
||||
## Overview
|
||||
|
||||
This extension provides Git operations as an optional, self-contained module. It manages:
|
||||
|
||||
- **Repository initialization** with configurable commit messages
|
||||
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering
|
||||
- **Branch validation** to ensure branches follow naming conventions
|
||||
- **Git remote detection** for GitHub integration (e.g., issue creation)
|
||||
- **Auto-commit** after core commands (configurable per-command with custom messages)
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `speckit.git.initialize` | Initialize a Git repository with a configurable commit message |
|
||||
| `speckit.git.feature` | Create a feature branch with sequential or timestamp numbering |
|
||||
| `speckit.git.validate` | Validate current branch follows feature branch naming conventions |
|
||||
| `speckit.git.remote` | Detect Git remote URL for GitHub integration |
|
||||
| `speckit.git.commit` | Auto-commit changes (configurable per-command enable/disable and messages) |
|
||||
|
||||
## Hooks
|
||||
|
||||
| Event | Command | Optional | Description |
|
||||
|-------|---------|----------|-------------|
|
||||
| `before_constitution` | `speckit.git.initialize` | No | Init git repo before constitution |
|
||||
| `before_specify` | `speckit.git.feature` | No | Create feature branch before specification |
|
||||
| `before_clarify` | `speckit.git.commit` | Yes | Commit outstanding changes before clarification |
|
||||
| `before_plan` | `speckit.git.commit` | Yes | Commit outstanding changes before planning |
|
||||
| `before_tasks` | `speckit.git.commit` | Yes | Commit outstanding changes before task generation |
|
||||
| `before_implement` | `speckit.git.commit` | Yes | Commit outstanding changes before implementation |
|
||||
| `before_checklist` | `speckit.git.commit` | Yes | Commit outstanding changes before checklist |
|
||||
| `before_analyze` | `speckit.git.commit` | Yes | Commit outstanding changes before analysis |
|
||||
| `before_taskstoissues` | `speckit.git.commit` | Yes | Commit outstanding changes before issue sync |
|
||||
| `after_constitution` | `speckit.git.commit` | Yes | Auto-commit after constitution update |
|
||||
| `after_specify` | `speckit.git.commit` | Yes | Auto-commit after specification |
|
||||
| `after_clarify` | `speckit.git.commit` | Yes | Auto-commit after clarification |
|
||||
| `after_plan` | `speckit.git.commit` | Yes | Auto-commit after planning |
|
||||
| `after_tasks` | `speckit.git.commit` | Yes | Auto-commit after task generation |
|
||||
| `after_implement` | `speckit.git.commit` | Yes | Auto-commit after implementation |
|
||||
| `after_checklist` | `speckit.git.commit` | Yes | Auto-commit after checklist |
|
||||
| `after_analyze` | `speckit.git.commit` | Yes | Auto-commit after analysis |
|
||||
| `after_taskstoissues` | `speckit.git.commit` | Yes | Auto-commit after issue sync |
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is stored in `.specify/extensions/git/git-config.yml`:
|
||||
|
||||
```yaml
|
||||
# Branch numbering strategy: "sequential" or "timestamp"
|
||||
branch_numbering: sequential
|
||||
|
||||
# Custom commit message for git init
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Auto-commit per command (all disabled by default)
|
||||
# Example: enable auto-commit after specify
|
||||
auto_commit:
|
||||
default: false
|
||||
after_specify:
|
||||
enabled: true
|
||||
message: "[Spec Kit] Add specification"
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install the bundled git extension (no network required)
|
||||
specify extension add git
|
||||
```
|
||||
|
||||
## Disabling
|
||||
|
||||
```bash
|
||||
# Disable the git extension (spec creation continues without branching)
|
||||
specify extension disable git
|
||||
|
||||
# Re-enable it
|
||||
specify extension enable git
|
||||
```
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
When Git is not installed or the directory is not a Git repository:
|
||||
- Spec directories are still created under `specs/`
|
||||
- Branch creation is skipped with a warning
|
||||
- Branch validation is skipped with a warning
|
||||
- Remote detection returns empty results
|
||||
|
||||
## Scripts
|
||||
|
||||
The extension bundles cross-platform scripts:
|
||||
|
||||
- `scripts/bash/create-new-feature.sh` — Bash implementation
|
||||
- `scripts/bash/git-common.sh` — Shared Git utilities (Bash)
|
||||
- `scripts/powershell/create-new-feature.ps1` — PowerShell implementation
|
||||
- `scripts/powershell/git-common.ps1` — Shared Git utilities (PowerShell)
|
||||
@ -1,48 +0,0 @@
|
||||
---
|
||||
description: "Auto-commit changes after a Spec Kit command completes"
|
||||
---
|
||||
|
||||
# Auto-Commit Changes
|
||||
|
||||
Automatically stage and commit all changes after a Spec Kit command completes.
|
||||
|
||||
## Behavior
|
||||
|
||||
This command is invoked as a hook after (or before) core commands. It:
|
||||
|
||||
1. Determines the event name from the hook context (e.g., if invoked as an `after_specify` hook, the event is `after_specify`; if `before_plan`, the event is `before_plan`)
|
||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
||||
3. Looks up the specific event key to see if auto-commit is enabled
|
||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
||||
5. Uses the per-command `message` if configured, otherwise a default message
|
||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
||||
|
||||
## Execution
|
||||
|
||||
Determine the event name from the hook that triggered this command, then run the script:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
||||
|
||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
||||
|
||||
## Configuration
|
||||
|
||||
In `.specify/extensions/git/git-config.yml`:
|
||||
|
||||
```yaml
|
||||
auto_commit:
|
||||
default: false # Global toggle — set true to enable for all commands
|
||||
after_specify:
|
||||
enabled: true # Override per-command
|
||||
message: "[Spec Kit] Add specification"
|
||||
after_plan:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add implementation plan"
|
||||
```
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
||||
- If no config file exists: skips (disabled by default)
|
||||
- If no changes to commit: skips with a message
|
||||
@ -1,67 +0,0 @@
|
||||
---
|
||||
description: "Create a feature branch with sequential or timestamp numbering"
|
||||
---
|
||||
|
||||
# Create Feature Branch
|
||||
|
||||
Create and switch to a new git feature branch for the given specification. This command handles **branch creation only** — the spec directory and files are created by the core `/speckit.specify` workflow.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Environment Variable Override
|
||||
|
||||
If the user explicitly provided `GIT_BRANCH_NAME` (e.g., via environment variable, argument, or in their request), pass it through to the script by setting the `GIT_BRANCH_NAME` environment variable before invoking the script. When `GIT_BRANCH_NAME` is set:
|
||||
- The script uses the exact value as the branch name, bypassing all prefix/suffix generation
|
||||
- `--short-name`, `--number`, and `--timestamp` flags are ignored
|
||||
- `FEATURE_NUM` is extracted from the name if it starts with a numeric prefix, otherwise set to the full branch name
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Verify Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||
- If Git is not available, warn the user and skip branch creation
|
||||
|
||||
## Branch Numbering Mode
|
||||
|
||||
Determine the branch numbering strategy by checking configuration in this order:
|
||||
|
||||
1. Check `.specify/extensions/git/git-config.yml` for `branch_numbering` value
|
||||
2. Check `.specify/init-options.json` for `branch_numbering` value (backward compatibility)
|
||||
3. Default to `sequential` if neither exists
|
||||
|
||||
## Execution
|
||||
|
||||
Generate a concise short name (2-4 words) for the branch:
|
||||
- Analyze the feature description and extract the most meaningful keywords
|
||||
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
|
||||
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
|
||||
|
||||
Run the appropriate script based on your platform:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --short-name "<short-name>" "<feature description>"`
|
||||
- **Bash (timestamp)**: `.specify/extensions/git/scripts/bash/create-new-feature.sh --json --timestamp --short-name "<short-name>" "<feature description>"`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -ShortName "<short-name>" "<feature description>"`
|
||||
- **PowerShell (timestamp)**: `.specify/extensions/git/scripts/powershell/create-new-feature.ps1 -Json -Timestamp -ShortName "<short-name>" "<feature description>"`
|
||||
|
||||
**IMPORTANT**:
|
||||
- Do NOT pass `--number` — the script determines the correct next number automatically
|
||||
- Always include the JSON flag (`--json` for Bash, `-Json` for PowerShell) so the output can be parsed reliably
|
||||
- You must only ever run this script once per feature
|
||||
- The JSON output will contain `BRANCH_NAME` and `FEATURE_NUM`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed or the current directory is not a Git repository:
|
||||
- Branch creation is skipped with a warning: `[specify] Warning: Git repository not detected; skipped branch creation`
|
||||
- The script still outputs `BRANCH_NAME` and `FEATURE_NUM` so the caller can reference them
|
||||
|
||||
## Output
|
||||
|
||||
The script outputs JSON with:
|
||||
- `BRANCH_NAME`: The branch name (e.g., `003-user-auth` or `20260319-143022-user-auth`)
|
||||
- `FEATURE_NUM`: The numeric or timestamp prefix used
|
||||
@ -1,49 +0,0 @@
|
||||
---
|
||||
description: "Initialize a Git repository with an initial commit"
|
||||
---
|
||||
|
||||
# Initialize Git Repository
|
||||
|
||||
Initialize a Git repository in the current project directory if one does not already exist.
|
||||
|
||||
## Execution
|
||||
|
||||
Run the appropriate script from the project root:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/initialize-repo.sh`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/initialize-repo.ps1`
|
||||
|
||||
If the extension scripts are not found, fall back to:
|
||||
- **Bash**: `git init && git add . && git commit -m "Initial commit from Specify template"`
|
||||
- **PowerShell**: `git init; git add .; git commit -m "Initial commit from Specify template"`
|
||||
|
||||
The script handles all checks internally:
|
||||
- Skips if Git is not available
|
||||
- Skips if already inside a Git repository
|
||||
- Runs `git init`, `git add .`, and `git commit` with an initial commit message
|
||||
|
||||
## Customization
|
||||
|
||||
Replace the script to add project-specific Git initialization steps:
|
||||
- Custom `.gitignore` templates
|
||||
- Default branch naming (`git config init.defaultBranch`)
|
||||
- Git LFS setup
|
||||
- Git hooks installation
|
||||
- Commit signing configuration
|
||||
- Git Flow initialization
|
||||
|
||||
## Output
|
||||
|
||||
On success:
|
||||
- `✓ Git repository initialized`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed:
|
||||
- Warn the user
|
||||
- Skip repository initialization
|
||||
- The project continues to function without Git (specs can still be created under `specs/`)
|
||||
|
||||
If Git is installed but `git init`, `git add .`, or `git commit` fails:
|
||||
- Surface the error to the user
|
||||
- Stop this command rather than continuing with a partially initialized repository
|
||||
@ -1,45 +0,0 @@
|
||||
---
|
||||
description: "Detect Git remote URL for GitHub integration"
|
||||
---
|
||||
|
||||
# Detect Git Remote URL
|
||||
|
||||
Detect the Git remote URL for integration with GitHub services (e.g., issue creation).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||
- If Git is not available, output a warning and return empty:
|
||||
```
|
||||
[specify] Warning: Git repository not detected; cannot determine remote URL
|
||||
```
|
||||
|
||||
## Execution
|
||||
|
||||
Run the following command to get the remote URL:
|
||||
|
||||
```bash
|
||||
git config --get remote.origin.url
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
Parse the remote URL and determine:
|
||||
|
||||
1. **Repository owner**: Extract from the URL (e.g., `github` from `https://github.com/github/spec-kit.git`)
|
||||
2. **Repository name**: Extract from the URL (e.g., `spec-kit` from `https://github.com/github/spec-kit.git`)
|
||||
3. **Is GitHub**: Whether the remote points to a GitHub repository
|
||||
|
||||
Supported URL formats:
|
||||
- HTTPS: `https://github.com/<owner>/<repo>.git`
|
||||
- SSH: `git@github.com:<owner>/<repo>.git`
|
||||
|
||||
> [!CAUTION]
|
||||
> ONLY report a GitHub repository if the remote URL actually points to github.com.
|
||||
> Do NOT assume the remote is GitHub if the URL format doesn't match.
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed, the directory is not a Git repository, or no remote is configured:
|
||||
- Return an empty result
|
||||
- Do NOT error — other workflows should continue without Git remote information
|
||||
@ -1,49 +0,0 @@
|
||||
---
|
||||
description: "Validate current branch follows feature branch naming conventions"
|
||||
---
|
||||
|
||||
# Validate Feature Branch
|
||||
|
||||
Validate that the current Git branch follows the expected feature branch naming conventions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Check if Git is available by running `git rev-parse --is-inside-work-tree 2>/dev/null`
|
||||
- If Git is not available, output a warning and skip validation:
|
||||
```
|
||||
[specify] Warning: Git repository not detected; skipped branch validation
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
Get the current branch name:
|
||||
|
||||
```bash
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
```
|
||||
|
||||
The branch name must match one of these patterns:
|
||||
|
||||
1. **Sequential**: `^[0-9]{3,}-` (e.g., `001-feature-name`, `042-fix-bug`, `1000-big-feature`)
|
||||
2. **Timestamp**: `^[0-9]{8}-[0-9]{6}-` (e.g., `20260319-143022-feature-name`)
|
||||
|
||||
## Execution
|
||||
|
||||
If on a feature branch (matches either pattern):
|
||||
- Output: `✓ On feature branch: <branch-name>`
|
||||
- Check if the corresponding spec directory exists under `specs/`:
|
||||
- For sequential branches, look for `specs/<prefix>-*` where prefix matches the numeric portion
|
||||
- For timestamp branches, look for `specs/<prefix>-*` where prefix matches the `YYYYMMDD-HHMMSS` portion
|
||||
- If spec directory exists: `✓ Spec directory found: <path>`
|
||||
- If spec directory missing: `⚠ No spec directory found for prefix <prefix>`
|
||||
|
||||
If NOT on a feature branch:
|
||||
- Output: `✗ Not on a feature branch. Current branch: <branch-name>`
|
||||
- Output: `Feature branches should be named like: 001-feature-name or 20260319-143022-feature-name`
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
If Git is not installed or the directory is not a Git repository:
|
||||
- Check the `SPECIFY_FEATURE` environment variable as a fallback
|
||||
- If set, validate that value against the naming patterns
|
||||
- If not set, skip validation with a warning
|
||||
@ -1,62 +0,0 @@
|
||||
# Git Branching Workflow Extension Configuration
|
||||
# Copied to .specify/extensions/git/git-config.yml on install
|
||||
|
||||
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
||||
branch_numbering: sequential
|
||||
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Auto-commit before/after core commands.
|
||||
# Set "default" to enable for all commands, then override per-command.
|
||||
# Each key can be true/false. Message is customizable per-command.
|
||||
auto_commit:
|
||||
default: false
|
||||
before_clarify:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before clarification"
|
||||
before_plan:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before planning"
|
||||
before_tasks:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before task generation"
|
||||
before_implement:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before implementation"
|
||||
before_checklist:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before checklist"
|
||||
before_analyze:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before analysis"
|
||||
before_taskstoissues:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before issue sync"
|
||||
after_constitution:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add project constitution"
|
||||
after_specify:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add specification"
|
||||
after_clarify:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Clarify specification"
|
||||
after_plan:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add implementation plan"
|
||||
after_tasks:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add tasks"
|
||||
after_implement:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Implementation progress"
|
||||
after_checklist:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add checklist"
|
||||
after_analyze:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add analysis report"
|
||||
after_taskstoissues:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Sync tasks to issues"
|
||||
@ -1,140 +0,0 @@
|
||||
schema_version: "1.0"
|
||||
|
||||
extension:
|
||||
id: git
|
||||
name: "Git Branching Workflow"
|
||||
version: "1.0.0"
|
||||
description: "Feature branch creation, numbering (sequential/timestamp), validation, and Git remote detection"
|
||||
author: spec-kit-core
|
||||
repository: https://github.com/github/spec-kit
|
||||
license: MIT
|
||||
|
||||
requires:
|
||||
speckit_version: ">=0.2.0"
|
||||
tools:
|
||||
- name: git
|
||||
required: false
|
||||
|
||||
provides:
|
||||
commands:
|
||||
- name: speckit.git.feature
|
||||
file: commands/speckit.git.feature.md
|
||||
description: "Create a feature branch with sequential or timestamp numbering"
|
||||
- name: speckit.git.validate
|
||||
file: commands/speckit.git.validate.md
|
||||
description: "Validate current branch follows feature branch naming conventions"
|
||||
- name: speckit.git.remote
|
||||
file: commands/speckit.git.remote.md
|
||||
description: "Detect Git remote URL for GitHub integration"
|
||||
- name: speckit.git.initialize
|
||||
file: commands/speckit.git.initialize.md
|
||||
description: "Initialize a Git repository with an initial commit"
|
||||
- name: speckit.git.commit
|
||||
file: commands/speckit.git.commit.md
|
||||
description: "Auto-commit changes after a Spec Kit command completes"
|
||||
|
||||
config:
|
||||
- name: "git-config.yml"
|
||||
template: "config-template.yml"
|
||||
description: "Git branching configuration"
|
||||
required: false
|
||||
|
||||
hooks:
|
||||
before_constitution:
|
||||
command: speckit.git.initialize
|
||||
optional: false
|
||||
description: "Initialize Git repository before constitution setup"
|
||||
before_specify:
|
||||
command: speckit.git.feature
|
||||
optional: false
|
||||
description: "Create feature branch before specification"
|
||||
before_clarify:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit outstanding changes before clarification?"
|
||||
description: "Auto-commit before spec clarification"
|
||||
before_plan:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit outstanding changes before planning?"
|
||||
description: "Auto-commit before implementation planning"
|
||||
before_tasks:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit outstanding changes before task generation?"
|
||||
description: "Auto-commit before task generation"
|
||||
before_implement:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit outstanding changes before implementation?"
|
||||
description: "Auto-commit before implementation"
|
||||
before_checklist:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit outstanding changes before checklist?"
|
||||
description: "Auto-commit before checklist generation"
|
||||
before_analyze:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit outstanding changes before analysis?"
|
||||
description: "Auto-commit before analysis"
|
||||
before_taskstoissues:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit outstanding changes before issue sync?"
|
||||
description: "Auto-commit before tasks-to-issues conversion"
|
||||
after_constitution:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit constitution changes?"
|
||||
description: "Auto-commit after constitution update"
|
||||
after_specify:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit specification changes?"
|
||||
description: "Auto-commit after specification"
|
||||
after_clarify:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit clarification changes?"
|
||||
description: "Auto-commit after spec clarification"
|
||||
after_plan:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit plan changes?"
|
||||
description: "Auto-commit after implementation planning"
|
||||
after_tasks:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit task changes?"
|
||||
description: "Auto-commit after task generation"
|
||||
after_implement:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit implementation changes?"
|
||||
description: "Auto-commit after implementation"
|
||||
after_checklist:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit checklist changes?"
|
||||
description: "Auto-commit after checklist generation"
|
||||
after_analyze:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit analysis results?"
|
||||
description: "Auto-commit after analysis"
|
||||
after_taskstoissues:
|
||||
command: speckit.git.commit
|
||||
optional: true
|
||||
prompt: "Commit after syncing issues?"
|
||||
description: "Auto-commit after tasks-to-issues conversion"
|
||||
|
||||
tags:
|
||||
- "git"
|
||||
- "branching"
|
||||
- "workflow"
|
||||
|
||||
config:
|
||||
defaults:
|
||||
branch_numbering: sequential
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
@ -1,62 +0,0 @@
|
||||
# Git Branching Workflow Extension Configuration
|
||||
# Copied to .specify/extensions/git/git-config.yml on install
|
||||
|
||||
# Branch numbering strategy: "sequential" (001, 002, ...) or "timestamp" (YYYYMMDD-HHMMSS)
|
||||
branch_numbering: sequential
|
||||
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Auto-commit before/after core commands.
|
||||
# Set "default" to enable for all commands, then override per-command.
|
||||
# Each key can be true/false. Message is customizable per-command.
|
||||
auto_commit:
|
||||
default: false
|
||||
before_clarify:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before clarification"
|
||||
before_plan:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before planning"
|
||||
before_tasks:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before task generation"
|
||||
before_implement:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before implementation"
|
||||
before_checklist:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before checklist"
|
||||
before_analyze:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before analysis"
|
||||
before_taskstoissues:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Save progress before issue sync"
|
||||
after_constitution:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add project constitution"
|
||||
after_specify:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add specification"
|
||||
after_clarify:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Clarify specification"
|
||||
after_plan:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add implementation plan"
|
||||
after_tasks:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add tasks"
|
||||
after_implement:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Implementation progress"
|
||||
after_checklist:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add checklist"
|
||||
after_analyze:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Add analysis report"
|
||||
after_taskstoissues:
|
||||
enabled: false
|
||||
message: "[Spec Kit] Sync tasks to issues"
|
||||
@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Git extension: auto-commit.sh
|
||||
# Automatically commit changes after a Spec Kit command completes.
|
||||
# Checks per-command config keys in git-config.yml before committing.
|
||||
#
|
||||
# Usage: auto-commit.sh <event_name>
|
||||
# e.g.: auto-commit.sh after_specify
|
||||
|
||||
set -e
|
||||
|
||||
EVENT_NAME="${1:-}"
|
||||
if [ -z "$EVENT_NAME" ]; then
|
||||
echo "Usage: $0 <event_name>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
_find_project_root() {
|
||||
local dir="$1"
|
||||
while [ "$dir" != "/" ]; do
|
||||
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Check if git is available
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "[specify] Warning: Git not found; skipped auto-commit" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "[specify] Warning: Not a Git repository; skipped auto-commit" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read per-command config from git-config.yml
|
||||
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
||||
_enabled=false
|
||||
_commit_msg=""
|
||||
|
||||
if [ -f "$_config_file" ]; then
|
||||
# Parse the auto_commit section for this event.
|
||||
# Look for auto_commit.<event_name>.enabled and .message
|
||||
# Also check auto_commit.default as fallback.
|
||||
_in_auto_commit=false
|
||||
_in_event=false
|
||||
_default_enabled=false
|
||||
|
||||
while IFS= read -r _line; do
|
||||
# Detect auto_commit: section
|
||||
if echo "$_line" | grep -q '^auto_commit:'; then
|
||||
_in_auto_commit=true
|
||||
_in_event=false
|
||||
continue
|
||||
fi
|
||||
|
||||
# Exit auto_commit section on next top-level key
|
||||
if $_in_auto_commit && echo "$_line" | grep -Eq '^[a-z]'; then
|
||||
break
|
||||
fi
|
||||
|
||||
if $_in_auto_commit; then
|
||||
# Check default key
|
||||
if echo "$_line" | grep -Eq "^[[:space:]]+default:[[:space:]]"; then
|
||||
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||
[ "$_val" = "true" ] && _default_enabled=true
|
||||
fi
|
||||
|
||||
# Detect our event subsection
|
||||
if echo "$_line" | grep -Eq "^[[:space:]]+${EVENT_NAME}:"; then
|
||||
_in_event=true
|
||||
continue
|
||||
fi
|
||||
|
||||
# Inside our event subsection
|
||||
if $_in_event; then
|
||||
# Exit on next sibling key (same indent level as event name)
|
||||
if echo "$_line" | grep -Eq '^[[:space:]]{2}[a-z]' && ! echo "$_line" | grep -Eq '^[[:space:]]{4}'; then
|
||||
_in_event=false
|
||||
continue
|
||||
fi
|
||||
if echo "$_line" | grep -Eq '[[:space:]]+enabled:'; then
|
||||
_val=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
|
||||
[ "$_val" = "true" ] && _enabled=true
|
||||
[ "$_val" = "false" ] && _enabled=false
|
||||
fi
|
||||
if echo "$_line" | grep -Eq '[[:space:]]+message:'; then
|
||||
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
done < "$_config_file"
|
||||
|
||||
# If event-specific key not found, use default
|
||||
if [ "$_enabled" = "false" ] && [ "$_default_enabled" = "true" ]; then
|
||||
# Only use default if the event wasn't explicitly set to false
|
||||
# Check if event section existed at all
|
||||
if ! grep -q "^[[:space:]]*${EVENT_NAME}:" "$_config_file" 2>/dev/null; then
|
||||
_enabled=true
|
||||
fi
|
||||
fi
|
||||
else
|
||||
# No config file — auto-commit disabled by default
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$_enabled" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if there are changes to commit
|
||||
if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null && [ -z "$(git ls-files --others --exclude-standard 2>/dev/null)" ]; then
|
||||
echo "[specify] No changes to commit after $EVENT_NAME" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Derive a human-readable command name from the event
|
||||
# e.g., after_specify -> specify, before_plan -> plan
|
||||
_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//')
|
||||
_phase=$(echo "$EVENT_NAME" | grep -q '^before_' && echo 'before' || echo 'after')
|
||||
|
||||
# Use custom message if configured, otherwise default
|
||||
if [ -z "$_commit_msg" ]; then
|
||||
_commit_msg="[Spec Kit] Auto-commit ${_phase} ${_command_name}"
|
||||
fi
|
||||
|
||||
# Stage and commit
|
||||
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
|
||||
_git_out=$(git commit -q -m "$_commit_msg" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
|
||||
|
||||
echo "[OK] Changes committed ${_phase} ${_command_name}" >&2
|
||||
@ -1,453 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Git extension: create-new-feature.sh
|
||||
# Adapted from core scripts/bash/create-new-feature.sh for extension layout.
|
||||
# Sources common.sh from the project's installed scripts, falling back to
|
||||
# git-common.sh for minimal git helpers.
|
||||
|
||||
set -e
|
||||
|
||||
JSON_MODE=false
|
||||
DRY_RUN=false
|
||||
ALLOW_EXISTING=false
|
||||
SHORT_NAME=""
|
||||
BRANCH_NUMBER=""
|
||||
USE_TIMESTAMP=false
|
||||
ARGS=()
|
||||
i=1
|
||||
while [ $i -le $# ]; do
|
||||
arg="${!i}"
|
||||
case "$arg" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
;;
|
||||
--allow-existing-branch)
|
||||
ALLOW_EXISTING=true
|
||||
;;
|
||||
--short-name)
|
||||
if [ $((i + 1)) -gt $# ]; then
|
||||
echo 'Error: --short-name requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
i=$((i + 1))
|
||||
next_arg="${!i}"
|
||||
if [[ "$next_arg" == --* ]]; then
|
||||
echo 'Error: --short-name requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
SHORT_NAME="$next_arg"
|
||||
;;
|
||||
--number)
|
||||
if [ $((i + 1)) -gt $# ]; then
|
||||
echo 'Error: --number requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
i=$((i + 1))
|
||||
next_arg="${!i}"
|
||||
if [[ "$next_arg" == --* ]]; then
|
||||
echo 'Error: --number requires a value' >&2
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_NUMBER="$next_arg"
|
||||
if [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
echo 'Error: --number must be a non-negative integer' >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
--timestamp)
|
||||
USE_TIMESTAMP=true
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --json Output in JSON format"
|
||||
echo " --dry-run Compute branch name without creating the branch"
|
||||
echo " --allow-existing-branch Switch to branch if it already exists instead of failing"
|
||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the branch"
|
||||
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
echo "Environment variables:"
|
||||
echo " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 'Add user authentication system' --short-name 'user-auth'"
|
||||
echo " $0 'Implement OAuth2 integration for API' --number 5"
|
||||
echo " $0 --timestamp --short-name 'user-auth' 'Add user authentication'"
|
||||
echo " GIT_BRANCH_NAME=my-branch $0 'feature description'"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
ARGS+=("$arg")
|
||||
;;
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
FEATURE_DESCRIPTION="${ARGS[*]}"
|
||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
echo "Usage: $0 [--json] [--dry-run] [--allow-existing-branch] [--short-name <name>] [--number N] [--timestamp] <feature_description>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Trim whitespace and validate description is not empty
|
||||
FEATURE_DESCRIPTION=$(echo "$FEATURE_DESCRIPTION" | xargs)
|
||||
if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
echo "Error: Feature description cannot be empty or contain only whitespace" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Function to get highest number from specs directory
|
||||
get_highest_from_specs() {
|
||||
local specs_dir="$1"
|
||||
local highest=0
|
||||
|
||||
if [ -d "$specs_dir" ]; then
|
||||
for dir in "$specs_dir"/*; do
|
||||
[ -d "$dir" ] || continue
|
||||
dirname=$(basename "$dir")
|
||||
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to get highest number from git branches
|
||||
get_highest_from_branches() {
|
||||
git branch -a 2>/dev/null | sed 's/^[* ]*//; s|^remotes/[^/]*/||' | _extract_highest_number
|
||||
}
|
||||
|
||||
# Extract the highest sequential feature number from a list of ref names (one per line).
|
||||
_extract_highest_number() {
|
||||
local highest=0
|
||||
while IFS= read -r name; do
|
||||
[ -z "$name" ] && continue
|
||||
if echo "$name" | grep -Eq '^[0-9]{3,}-' && ! echo "$name" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
number=$(echo "$name" | grep -Eo '^[0-9]+' || echo "0")
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to get highest number from remote branches without fetching (side-effect-free)
|
||||
get_highest_from_remote_refs() {
|
||||
local highest=0
|
||||
|
||||
for remote in $(git remote 2>/dev/null); do
|
||||
local remote_highest
|
||||
remote_highest=$(GIT_TERMINAL_PROMPT=0 git ls-remote --heads "$remote" 2>/dev/null | sed 's|.*refs/heads/||' | _extract_highest_number)
|
||||
if [ "$remote_highest" -gt "$highest" ]; then
|
||||
highest=$remote_highest
|
||||
fi
|
||||
done
|
||||
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Function to check existing branches and return next available number.
|
||||
check_existing_branches() {
|
||||
local specs_dir="$1"
|
||||
local skip_fetch="${2:-false}"
|
||||
|
||||
if [ "$skip_fetch" = true ]; then
|
||||
local highest_remote=$(get_highest_from_remote_refs)
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
if [ "$highest_remote" -gt "$highest_branch" ]; then
|
||||
highest_branch=$highest_remote
|
||||
fi
|
||||
else
|
||||
git fetch --all --prune >/dev/null 2>&1 || true
|
||||
local highest_branch=$(get_highest_from_branches)
|
||||
fi
|
||||
|
||||
local highest_spec=$(get_highest_from_specs "$specs_dir")
|
||||
|
||||
local max_num=$highest_branch
|
||||
if [ "$highest_spec" -gt "$max_num" ]; then
|
||||
max_num=$highest_spec
|
||||
fi
|
||||
|
||||
echo $((max_num + 1))
|
||||
}
|
||||
|
||||
# Function to clean and format a branch name
|
||||
clean_branch_name() {
|
||||
local name="$1"
|
||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source common.sh for resolve_template, json_escape, get_repo_root, has_git.
|
||||
#
|
||||
# Search locations in priority order:
|
||||
# 1. .specify/scripts/bash/common.sh under the project root (installed project)
|
||||
# 2. scripts/bash/common.sh under the project root (source checkout fallback)
|
||||
# 3. git-common.sh next to this script (minimal fallback — lacks resolve_template)
|
||||
# ---------------------------------------------------------------------------
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Find project root by walking up from the script location
|
||||
_find_project_root() {
|
||||
local dir="$1"
|
||||
while [ "$dir" != "/" ]; do
|
||||
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
_common_loaded=false
|
||||
_PROJECT_ROOT=$(_find_project_root "$SCRIPT_DIR") || true
|
||||
|
||||
if [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/.specify/scripts/bash/common.sh" ]; then
|
||||
source "$_PROJECT_ROOT/.specify/scripts/bash/common.sh"
|
||||
_common_loaded=true
|
||||
elif [ -n "$_PROJECT_ROOT" ] && [ -f "$_PROJECT_ROOT/scripts/bash/common.sh" ]; then
|
||||
source "$_PROJECT_ROOT/scripts/bash/common.sh"
|
||||
_common_loaded=true
|
||||
elif [ -f "$SCRIPT_DIR/git-common.sh" ]; then
|
||||
source "$SCRIPT_DIR/git-common.sh"
|
||||
_common_loaded=true
|
||||
fi
|
||||
|
||||
if [ "$_common_loaded" != "true" ]; then
|
||||
echo "Error: Could not locate common.sh or git-common.sh. Please ensure the Specify core scripts are installed." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve repository root
|
||||
if type get_repo_root >/dev/null 2>&1; then
|
||||
REPO_ROOT=$(get_repo_root)
|
||||
elif git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
elif [ -n "$_PROJECT_ROOT" ]; then
|
||||
REPO_ROOT="$_PROJECT_ROOT"
|
||||
else
|
||||
echo "Error: Could not determine repository root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if git is available at this repo root
|
||||
if type has_git >/dev/null 2>&1; then
|
||||
if has_git "$REPO_ROOT"; then
|
||||
HAS_GIT=true
|
||||
else
|
||||
HAS_GIT=false
|
||||
fi
|
||||
elif git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
HAS_GIT=true
|
||||
else
|
||||
HAS_GIT=false
|
||||
fi
|
||||
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
SPECS_DIR="$REPO_ROOT/specs"
|
||||
|
||||
# Function to generate branch name with stop word filtering
|
||||
generate_branch_name() {
|
||||
local description="$1"
|
||||
|
||||
local stop_words="^(i|a|an|the|to|for|of|in|on|at|by|with|from|is|are|was|were|be|been|being|have|has|had|do|does|did|will|would|should|could|can|may|might|must|shall|this|that|these|those|my|your|our|their|want|need|add|get|set)$"
|
||||
|
||||
local clean_name=$(echo "$description" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/ /g')
|
||||
|
||||
local meaningful_words=()
|
||||
for word in $clean_name; do
|
||||
[ -z "$word" ] && continue
|
||||
if ! echo "$word" | grep -qiE "$stop_words"; then
|
||||
if [ ${#word} -ge 3 ]; then
|
||||
meaningful_words+=("$word")
|
||||
elif echo "$description" | grep -qw -- "${word^^}"; then
|
||||
meaningful_words+=("$word")
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#meaningful_words[@]} -gt 0 ]; then
|
||||
local max_words=3
|
||||
if [ ${#meaningful_words[@]} -eq 4 ]; then max_words=4; fi
|
||||
|
||||
local result=""
|
||||
local count=0
|
||||
for word in "${meaningful_words[@]}"; do
|
||||
if [ $count -ge $max_words ]; then break; fi
|
||||
if [ -n "$result" ]; then result="$result-"; fi
|
||||
result="$result$word"
|
||||
count=$((count + 1))
|
||||
done
|
||||
echo "$result"
|
||||
else
|
||||
local cleaned=$(clean_branch_name "$description")
|
||||
echo "$cleaned" | tr '-' '\n' | grep -v '^$' | head -3 | tr '\n' '-' | sed 's/-$//'
|
||||
fi
|
||||
}
|
||||
|
||||
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
||||
if [ -n "${GIT_BRANCH_NAME:-}" ]; then
|
||||
BRANCH_NAME="$GIT_BRANCH_NAME"
|
||||
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
|
||||
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^[0-9]+ pattern
|
||||
if echo "$BRANCH_NAME" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]{8}-[0-9]{6}')
|
||||
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
|
||||
elif echo "$BRANCH_NAME" | grep -Eq '^[0-9]+-'; then
|
||||
FEATURE_NUM=$(echo "$BRANCH_NAME" | grep -Eo '^[0-9]+')
|
||||
BRANCH_SUFFIX="${BRANCH_NAME#${FEATURE_NUM}-}"
|
||||
else
|
||||
FEATURE_NUM="$BRANCH_NAME"
|
||||
BRANCH_SUFFIX="$BRANCH_NAME"
|
||||
fi
|
||||
else
|
||||
# Generate branch name
|
||||
if [ -n "$SHORT_NAME" ]; then
|
||||
BRANCH_SUFFIX=$(clean_branch_name "$SHORT_NAME")
|
||||
else
|
||||
BRANCH_SUFFIX=$(generate_branch_name "$FEATURE_DESCRIPTION")
|
||||
fi
|
||||
|
||||
# Warn if --number and --timestamp are both specified
|
||||
if [ "$USE_TIMESTAMP" = true ] && [ -n "$BRANCH_NUMBER" ]; then
|
||||
>&2 echo "[specify] Warning: --number is ignored when --timestamp is used"
|
||||
BRANCH_NUMBER=""
|
||||
fi
|
||||
|
||||
# Determine branch prefix
|
||||
if [ "$USE_TIMESTAMP" = true ]; then
|
||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
else
|
||||
if [ -z "$BRANCH_NUMBER" ]; then
|
||||
if [ "$DRY_RUN" = true ] && [ "$HAS_GIT" = true ]; then
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR" true)
|
||||
elif [ "$DRY_RUN" = true ]; then
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
elif [ "$HAS_GIT" = true ]; then
|
||||
BRANCH_NUMBER=$(check_existing_branches "$SPECS_DIR")
|
||||
else
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names
|
||||
MAX_BRANCH_LENGTH=244
|
||||
_byte_length() { printf '%s' "$1" | LC_ALL=C wc -c | tr -d ' '; }
|
||||
BRANCH_BYTE_LEN=$(_byte_length "$BRANCH_NAME")
|
||||
if [ -n "${GIT_BRANCH_NAME:-}" ] && [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
|
||||
>&2 echo "Error: GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is ${BRANCH_BYTE_LEN} bytes."
|
||||
exit 1
|
||||
elif [ "$BRANCH_BYTE_LEN" -gt $MAX_BRANCH_LENGTH ]; then
|
||||
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
|
||||
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
||||
|
||||
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
||||
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
||||
|
||||
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
||||
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
||||
|
||||
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
||||
fi
|
||||
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
if [ "$HAS_GIT" = true ]; then
|
||||
branch_create_error=""
|
||||
if ! branch_create_error=$(git checkout -q -b "$BRANCH_NAME" 2>&1); then
|
||||
current_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
||||
if git branch --list "$BRANCH_NAME" | grep -q .; then
|
||||
if [ "$ALLOW_EXISTING" = true ]; then
|
||||
if [ "$current_branch" = "$BRANCH_NAME" ]; then
|
||||
:
|
||||
elif ! switch_branch_error=$(git checkout -q "$BRANCH_NAME" 2>&1); then
|
||||
>&2 echo "Error: Failed to switch to existing branch '$BRANCH_NAME'. Please resolve any local changes or conflicts and try again."
|
||||
if [ -n "$switch_branch_error" ]; then
|
||||
>&2 printf '%s\n' "$switch_branch_error"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
elif [ "$USE_TIMESTAMP" = true ]; then
|
||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Rerun to get a new timestamp or use a different --short-name."
|
||||
exit 1
|
||||
else
|
||||
>&2 echo "Error: Branch '$BRANCH_NAME' already exists. Please use a different feature name or specify a different number with --number."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
>&2 echo "Error: Failed to create git branch '$BRANCH_NAME'."
|
||||
if [ -n "$branch_create_error" ]; then
|
||||
>&2 printf '%s\n' "$branch_create_error"
|
||||
else
|
||||
>&2 echo "Please check your git configuration and try again."
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
else
|
||||
>&2 echo "[specify] Warning: Git repository not detected; skipped branch creation for $BRANCH_NAME"
|
||||
fi
|
||||
|
||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||
fi
|
||||
|
||||
if $JSON_MODE; then
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
jq -cn \
|
||||
--arg branch_name "$BRANCH_NAME" \
|
||||
--arg feature_num "$FEATURE_NUM" \
|
||||
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num,DRY_RUN:true}'
|
||||
else
|
||||
jq -cn \
|
||||
--arg branch_name "$BRANCH_NAME" \
|
||||
--arg feature_num "$FEATURE_NUM" \
|
||||
'{BRANCH_NAME:$branch_name,FEATURE_NUM:$feature_num}'
|
||||
fi
|
||||
else
|
||||
if type json_escape >/dev/null 2>&1; then
|
||||
_je_branch=$(json_escape "$BRANCH_NAME")
|
||||
_je_num=$(json_escape "$FEATURE_NUM")
|
||||
else
|
||||
_je_branch="$BRANCH_NAME"
|
||||
_je_num="$FEATURE_NUM"
|
||||
fi
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s","DRY_RUN":true}\n' "$_je_branch" "$_je_num"
|
||||
else
|
||||
printf '{"BRANCH_NAME":"%s","FEATURE_NUM":"%s"}\n' "$_je_branch" "$_je_num"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "BRANCH_NAME: $BRANCH_NAME"
|
||||
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
||||
fi
|
||||
fi
|
||||
@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Git-specific common functions for the git extension.
|
||||
# Extracted from scripts/bash/common.sh — contains only git-specific
|
||||
# branch validation and detection logic.
|
||||
|
||||
# Check if we have git available at the repo root
|
||||
has_git() {
|
||||
local repo_root="${1:-$(pwd)}"
|
||||
{ [ -d "$repo_root/.git" ] || [ -f "$repo_root/.git" ]; } && \
|
||||
command -v git >/dev/null 2>&1 && \
|
||||
git -C "$repo_root" rev-parse --is-inside-work-tree >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
|
||||
# Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
|
||||
spec_kit_effective_branch_name() {
|
||||
local raw="$1"
|
||||
if [[ "$raw" =~ ^([^/]+)/([^/]+)$ ]]; then
|
||||
printf '%s\n' "${BASH_REMATCH[2]}"
|
||||
else
|
||||
printf '%s\n' "$raw"
|
||||
fi
|
||||
}
|
||||
|
||||
# Validate that a branch name matches the expected feature branch pattern.
|
||||
# Accepts sequential (###-* with >=3 digits) or timestamp (YYYYMMDD-HHMMSS-*) formats.
|
||||
# Logic aligned with scripts/bash/common.sh check_feature_branch after effective-name normalization.
|
||||
check_feature_branch() {
|
||||
local raw="$1"
|
||||
local has_git_repo="$2"
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
if [[ "$has_git_repo" != "true" ]]; then
|
||||
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
|
||||
return 0
|
||||
fi
|
||||
|
||||
local branch
|
||||
branch=$(spec_kit_effective_branch_name "$raw")
|
||||
|
||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||
local is_sequential=false
|
||||
if [[ "$branch" =~ ^[0-9]{3,}- ]] && [[ ! "$branch" =~ ^[0-9]{7}-[0-9]{6}- ]] && [[ ! "$branch" =~ ^[0-9]{7,8}-[0-9]{6}$ ]]; then
|
||||
is_sequential=true
|
||||
fi
|
||||
if [[ "$is_sequential" != "true" ]] && [[ ! "$branch" =~ ^[0-9]{8}-[0-9]{6}- ]]; then
|
||||
echo "ERROR: Not on a feature branch. Current branch: $raw" >&2
|
||||
echo "Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Git extension: initialize-repo.sh
|
||||
# Initialize a Git repository with an initial commit.
|
||||
# Customizable — replace this script to add .gitignore templates,
|
||||
# default branch config, git-flow, LFS, signing, etc.
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Find project root
|
||||
_find_project_root() {
|
||||
local dir="$1"
|
||||
while [ "$dir" != "/" ]; do
|
||||
if [ -d "$dir/.specify" ] || [ -d "$dir/.git" ]; then
|
||||
echo "$dir"
|
||||
return 0
|
||||
fi
|
||||
dir="$(dirname "$dir")"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
REPO_ROOT=$(_find_project_root "$SCRIPT_DIR") || REPO_ROOT="$(pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Read commit message from extension config, fall back to default
|
||||
COMMIT_MSG="[Spec Kit] Initial commit"
|
||||
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
||||
if [ -f "$_config_file" ]; then
|
||||
_msg=$(grep '^init_commit_message:' "$_config_file" 2>/dev/null | sed 's/^init_commit_message:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
|
||||
if [ -n "$_msg" ]; then
|
||||
COMMIT_MSG="$_msg"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check if git is available
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "[specify] Warning: Git not found; skipped repository initialization" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if already a git repo
|
||||
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "[specify] Git repository already initialized; skipping" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Initialize
|
||||
_git_out=$(git init -q 2>&1) || { echo "[specify] Error: git init failed: $_git_out" >&2; exit 1; }
|
||||
_git_out=$(git add . 2>&1) || { echo "[specify] Error: git add failed: $_git_out" >&2; exit 1; }
|
||||
_git_out=$(git commit --allow-empty -q -m "$COMMIT_MSG" 2>&1) || { echo "[specify] Error: git commit failed: $_git_out" >&2; exit 1; }
|
||||
|
||||
echo "✓ Git repository initialized" >&2
|
||||
@ -1,169 +0,0 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Git extension: auto-commit.ps1
|
||||
# Automatically commit changes after a Spec Kit command completes.
|
||||
# Checks per-command config keys in git-config.yml before committing.
|
||||
#
|
||||
# Usage: auto-commit.ps1 <event_name>
|
||||
# e.g.: auto-commit.ps1 after_specify
|
||||
param(
|
||||
[Parameter(Position = 0, Mandatory = $true)]
|
||||
[string]$EventName
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Find-ProjectRoot {
|
||||
param([string]$StartDir)
|
||||
$current = Resolve-Path $StartDir
|
||||
while ($true) {
|
||||
foreach ($marker in @('.specify', '.git')) {
|
||||
if (Test-Path (Join-Path $current $marker)) {
|
||||
return $current
|
||||
}
|
||||
}
|
||||
$parent = Split-Path $current -Parent
|
||||
if ($parent -eq $current) { return $null }
|
||||
$current = $parent
|
||||
}
|
||||
}
|
||||
|
||||
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
|
||||
if (-not $repoRoot) { $repoRoot = Get-Location }
|
||||
Set-Location $repoRoot
|
||||
|
||||
# Check if git is available
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
Write-Warning "[specify] Warning: Git not found; skipped auto-commit"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Temporarily relax ErrorActionPreference so git stderr warnings
|
||||
# (e.g. CRLF notices on Windows) do not become terminating errors.
|
||||
$savedEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
git rev-parse --is-inside-work-tree 2>$null | Out-Null
|
||||
$isRepo = $LASTEXITCODE -eq 0
|
||||
} finally {
|
||||
$ErrorActionPreference = $savedEAP
|
||||
}
|
||||
if (-not $isRepo) {
|
||||
Write-Warning "[specify] Warning: Not a Git repository; skipped auto-commit"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Read per-command config from git-config.yml
|
||||
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
||||
$enabled = $false
|
||||
$commitMsg = ""
|
||||
|
||||
if (Test-Path $configFile) {
|
||||
# Parse YAML to find auto_commit section
|
||||
$inAutoCommit = $false
|
||||
$inEvent = $false
|
||||
$defaultEnabled = $false
|
||||
|
||||
foreach ($line in Get-Content $configFile) {
|
||||
# Detect auto_commit: section
|
||||
if ($line -match '^auto_commit:') {
|
||||
$inAutoCommit = $true
|
||||
$inEvent = $false
|
||||
continue
|
||||
}
|
||||
|
||||
# Exit auto_commit section on next top-level key
|
||||
if ($inAutoCommit -and $line -match '^[a-z]') {
|
||||
break
|
||||
}
|
||||
|
||||
if ($inAutoCommit) {
|
||||
# Check default key
|
||||
if ($line -match '^\s+default:\s*(.+)$') {
|
||||
$val = $matches[1].Trim().ToLower()
|
||||
if ($val -eq 'true') { $defaultEnabled = $true }
|
||||
}
|
||||
|
||||
# Detect our event subsection
|
||||
if ($line -match "^\s+${EventName}:") {
|
||||
$inEvent = $true
|
||||
continue
|
||||
}
|
||||
|
||||
# Inside our event subsection
|
||||
if ($inEvent) {
|
||||
# Exit on next sibling key (2-space indent, not 4+)
|
||||
if ($line -match '^\s{2}[a-z]' -and $line -notmatch '^\s{4}') {
|
||||
$inEvent = $false
|
||||
continue
|
||||
}
|
||||
if ($line -match '\s+enabled:\s*(.+)$') {
|
||||
$val = $matches[1].Trim().ToLower()
|
||||
if ($val -eq 'true') { $enabled = $true }
|
||||
if ($val -eq 'false') { $enabled = $false }
|
||||
}
|
||||
if ($line -match '\s+message:\s*(.+)$') {
|
||||
$commitMsg = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# If event-specific key not found, use default
|
||||
if (-not $enabled -and $defaultEnabled) {
|
||||
$hasEventKey = Select-String -Path $configFile -Pattern "^\s*${EventName}:" -Quiet
|
||||
if (-not $hasEventKey) {
|
||||
$enabled = $true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# No config file — auto-commit disabled by default
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $enabled) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Check if there are changes to commit
|
||||
# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate.
|
||||
$savedEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
git diff --quiet HEAD 2>$null; $d1 = $LASTEXITCODE
|
||||
git diff --cached --quiet 2>$null; $d2 = $LASTEXITCODE
|
||||
$untracked = git ls-files --others --exclude-standard 2>$null
|
||||
} finally {
|
||||
$ErrorActionPreference = $savedEAP
|
||||
}
|
||||
|
||||
if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) {
|
||||
Write-Host "[specify] No changes to commit after $EventName" -ForegroundColor DarkGray
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Derive a human-readable command name from the event
|
||||
$commandName = $EventName -replace '^after_', '' -replace '^before_', ''
|
||||
$phase = if ($EventName -match '^before_') { 'before' } else { 'after' }
|
||||
|
||||
# Use custom message if configured, otherwise default
|
||||
if (-not $commitMsg) {
|
||||
$commitMsg = "[Spec Kit] Auto-commit $phase $commandName"
|
||||
}
|
||||
|
||||
# Stage and commit
|
||||
# Relax ErrorActionPreference so CRLF warnings on stderr do not terminate,
|
||||
# while still allowing redirected error output to be captured for diagnostics.
|
||||
$savedEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = 'Continue'
|
||||
try {
|
||||
$out = git add . 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
|
||||
$out = git commit -q -m $commitMsg 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
|
||||
} catch {
|
||||
Write-Warning "[specify] Error: $_"
|
||||
exit 1
|
||||
} finally {
|
||||
$ErrorActionPreference = $savedEAP
|
||||
}
|
||||
|
||||
Write-Host "[OK] Changes committed $phase $commandName"
|
||||
@ -1,403 +0,0 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Git extension: create-new-feature.ps1
|
||||
# Adapted from core scripts/powershell/create-new-feature.ps1 for extension layout.
|
||||
# Sources common.ps1 from the project's installed scripts, falling back to
|
||||
# git-common.ps1 for minimal git helpers.
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$AllowExistingBranch,
|
||||
[switch]$DryRun,
|
||||
[string]$ShortName,
|
||||
[Parameter()]
|
||||
[long]$Number = 0,
|
||||
[switch]$Timestamp,
|
||||
[switch]$Help,
|
||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
||||
[string[]]$FeatureDescription
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($Help) {
|
||||
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
Write-Host ""
|
||||
Write-Host "Options:"
|
||||
Write-Host " -Json Output in JSON format"
|
||||
Write-Host " -DryRun Compute branch name without creating the branch"
|
||||
Write-Host " -AllowExistingBranch Switch to branch if it already exists instead of failing"
|
||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the branch"
|
||||
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
||||
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
Write-Host " -Help Show this help message"
|
||||
Write-Host ""
|
||||
Write-Host "Environment variables:"
|
||||
Write-Host " GIT_BRANCH_NAME Use this exact branch name, bypassing all prefix/suffix generation"
|
||||
Write-Host ""
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
|
||||
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$featureDesc = ($FeatureDescription -join ' ').Trim()
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
|
||||
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromSpecs {
|
||||
param([string]$SpecsDir)
|
||||
|
||||
[long]$highest = 0
|
||||
if (Test-Path $SpecsDir) {
|
||||
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
|
||||
if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
|
||||
[long]$num = 0
|
||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||
$highest = $num
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromNames {
|
||||
param([string[]]$Names)
|
||||
|
||||
[long]$highest = 0
|
||||
foreach ($name in $Names) {
|
||||
if ($name -match '^(\d{3,})-' -and $name -notmatch '^\d{8}-\d{6}-') {
|
||||
[long]$num = 0
|
||||
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
|
||||
$highest = $num
|
||||
}
|
||||
}
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromBranches {
|
||||
param()
|
||||
|
||||
try {
|
||||
$branches = git branch -a 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $branches) {
|
||||
$cleanNames = $branches | ForEach-Object {
|
||||
$_.Trim() -replace '^\*?\s+', '' -replace '^remotes/[^/]+/', ''
|
||||
}
|
||||
return Get-HighestNumberFromNames -Names $cleanNames
|
||||
}
|
||||
} catch {
|
||||
Write-Verbose "Could not check Git branches: $_"
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function Get-HighestNumberFromRemoteRefs {
|
||||
[long]$highest = 0
|
||||
try {
|
||||
$remotes = git remote 2>$null
|
||||
if ($remotes) {
|
||||
foreach ($remote in $remotes) {
|
||||
$env:GIT_TERMINAL_PROMPT = '0'
|
||||
$refs = git ls-remote --heads $remote 2>$null
|
||||
$env:GIT_TERMINAL_PROMPT = $null
|
||||
if ($LASTEXITCODE -eq 0 -and $refs) {
|
||||
$refNames = $refs | ForEach-Object {
|
||||
if ($_ -match 'refs/heads/(.+)$') { $matches[1] }
|
||||
} | Where-Object { $_ }
|
||||
$remoteHighest = Get-HighestNumberFromNames -Names $refNames
|
||||
if ($remoteHighest -gt $highest) { $highest = $remoteHighest }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Verbose "Could not query remote refs: $_"
|
||||
}
|
||||
return $highest
|
||||
}
|
||||
|
||||
function Get-NextBranchNumber {
|
||||
param(
|
||||
[string]$SpecsDir,
|
||||
[switch]$SkipFetch
|
||||
)
|
||||
|
||||
if ($SkipFetch) {
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
$highestRemote = Get-HighestNumberFromRemoteRefs
|
||||
$highestBranch = [Math]::Max($highestBranch, $highestRemote)
|
||||
} else {
|
||||
try {
|
||||
git fetch --all --prune 2>$null | Out-Null
|
||||
} catch { }
|
||||
$highestBranch = Get-HighestNumberFromBranches
|
||||
}
|
||||
|
||||
$highestSpec = Get-HighestNumberFromSpecs -SpecsDir $SpecsDir
|
||||
$maxNum = [Math]::Max($highestBranch, $highestSpec)
|
||||
return $maxNum + 1
|
||||
}
|
||||
|
||||
function ConvertTo-CleanBranchName {
|
||||
param([string]$Name)
|
||||
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source common.ps1 from the project's installed scripts.
|
||||
# Search locations in priority order:
|
||||
# 1. .specify/scripts/powershell/common.ps1 under the project root
|
||||
# 2. scripts/powershell/common.ps1 under the project root (source checkout)
|
||||
# 3. git-common.ps1 next to this script (minimal fallback)
|
||||
# ---------------------------------------------------------------------------
|
||||
function Find-ProjectRoot {
|
||||
param([string]$StartDir)
|
||||
$current = Resolve-Path $StartDir
|
||||
while ($true) {
|
||||
foreach ($marker in @('.specify', '.git')) {
|
||||
if (Test-Path (Join-Path $current $marker)) {
|
||||
return $current
|
||||
}
|
||||
}
|
||||
$parent = Split-Path $current -Parent
|
||||
if ($parent -eq $current) { return $null }
|
||||
$current = $parent
|
||||
}
|
||||
}
|
||||
|
||||
$projectRoot = Find-ProjectRoot -StartDir $PSScriptRoot
|
||||
$commonLoaded = $false
|
||||
|
||||
if ($projectRoot) {
|
||||
$candidates = @(
|
||||
(Join-Path $projectRoot ".specify/scripts/powershell/common.ps1"),
|
||||
(Join-Path $projectRoot "scripts/powershell/common.ps1")
|
||||
)
|
||||
foreach ($candidate in $candidates) {
|
||||
if (Test-Path $candidate) {
|
||||
. $candidate
|
||||
$commonLoaded = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $commonLoaded -and (Test-Path "$PSScriptRoot/git-common.ps1")) {
|
||||
. "$PSScriptRoot/git-common.ps1"
|
||||
$commonLoaded = $true
|
||||
}
|
||||
|
||||
if (-not $commonLoaded) {
|
||||
throw "Unable to locate common script file. Please ensure the Specify core scripts are installed."
|
||||
}
|
||||
|
||||
# Resolve repository root
|
||||
if (Get-Command Get-RepoRoot -ErrorAction SilentlyContinue) {
|
||||
$repoRoot = Get-RepoRoot
|
||||
} elseif ($projectRoot) {
|
||||
$repoRoot = $projectRoot
|
||||
} else {
|
||||
throw "Could not determine repository root."
|
||||
}
|
||||
|
||||
# Check if git is available
|
||||
if (Get-Command Test-HasGit -ErrorAction SilentlyContinue) {
|
||||
# Call without parameters for compatibility with core common.ps1 (no -RepoRoot param)
|
||||
# and git-common.ps1 (has -RepoRoot param with default).
|
||||
$hasGit = Test-HasGit
|
||||
} else {
|
||||
try {
|
||||
git -C $repoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
|
||||
$hasGit = ($LASTEXITCODE -eq 0)
|
||||
} catch {
|
||||
$hasGit = $false
|
||||
}
|
||||
}
|
||||
|
||||
Set-Location $repoRoot
|
||||
|
||||
$specsDir = Join-Path $repoRoot 'specs'
|
||||
|
||||
function Get-BranchName {
|
||||
param([string]$Description)
|
||||
|
||||
$stopWords = @(
|
||||
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
|
||||
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
|
||||
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
|
||||
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
|
||||
'want', 'need', 'add', 'get', 'set'
|
||||
)
|
||||
|
||||
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
|
||||
$words = $cleanName -split '\s+' | Where-Object { $_ }
|
||||
|
||||
$meaningfulWords = @()
|
||||
foreach ($word in $words) {
|
||||
if ($stopWords -contains $word) { continue }
|
||||
if ($word.Length -ge 3) {
|
||||
$meaningfulWords += $word
|
||||
} elseif ($Description -match "\b$($word.ToUpper())\b") {
|
||||
$meaningfulWords += $word
|
||||
}
|
||||
}
|
||||
|
||||
if ($meaningfulWords.Count -gt 0) {
|
||||
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
|
||||
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
|
||||
return $result
|
||||
} else {
|
||||
$result = ConvertTo-CleanBranchName -Name $Description
|
||||
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
|
||||
return [string]::Join('-', $fallbackWords)
|
||||
}
|
||||
}
|
||||
|
||||
# Check for GIT_BRANCH_NAME env var override (exact branch name, no prefix/suffix)
|
||||
if ($env:GIT_BRANCH_NAME) {
|
||||
$branchName = $env:GIT_BRANCH_NAME
|
||||
# Check 244-byte limit (UTF-8) for override names
|
||||
$branchNameUtf8ByteCount = [System.Text.Encoding]::UTF8.GetByteCount($branchName)
|
||||
if ($branchNameUtf8ByteCount -gt 244) {
|
||||
throw "GIT_BRANCH_NAME must be 244 bytes or fewer in UTF-8. Provided value is $branchNameUtf8ByteCount bytes; please supply a shorter override branch name."
|
||||
}
|
||||
# Extract FEATURE_NUM from the branch name if it starts with a numeric prefix
|
||||
# Check timestamp pattern first (YYYYMMDD-HHMMSS-) since it also matches the simpler ^\d+ pattern
|
||||
if ($branchName -match '^(\d{8}-\d{6})-') {
|
||||
$featureNum = $matches[1]
|
||||
} elseif ($branchName -match '^(\d+)-') {
|
||||
$featureNum = $matches[1]
|
||||
} else {
|
||||
$featureNum = $branchName
|
||||
}
|
||||
} else {
|
||||
if ($ShortName) {
|
||||
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
|
||||
} else {
|
||||
$branchSuffix = Get-BranchName -Description $featureDesc
|
||||
}
|
||||
|
||||
if ($Timestamp -and $Number -ne 0) {
|
||||
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
|
||||
$Number = 0
|
||||
}
|
||||
|
||||
if ($Timestamp) {
|
||||
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
} else {
|
||||
if ($Number -eq 0) {
|
||||
if ($DryRun -and $hasGit) {
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir -SkipFetch
|
||||
} elseif ($DryRun) {
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
} elseif ($hasGit) {
|
||||
$Number = Get-NextBranchNumber -SpecsDir $specsDir
|
||||
} else {
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
}
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $Number)
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
}
|
||||
}
|
||||
|
||||
$maxBranchLength = 244
|
||||
if ($branchName.Length -gt $maxBranchLength) {
|
||||
$prefixLength = $featureNum.Length + 1
|
||||
$maxSuffixLength = $maxBranchLength - $prefixLength
|
||||
|
||||
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
||||
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
||||
|
||||
$originalBranchName = $branchName
|
||||
$branchName = "$featureNum-$truncatedSuffix"
|
||||
|
||||
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
|
||||
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
|
||||
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
|
||||
}
|
||||
|
||||
if (-not $DryRun) {
|
||||
if ($hasGit) {
|
||||
$branchCreated = $false
|
||||
$branchCreateError = ''
|
||||
try {
|
||||
$branchCreateError = git checkout -q -b $branchName 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$branchCreated = $true
|
||||
}
|
||||
} catch {
|
||||
$branchCreateError = $_.Exception.Message
|
||||
}
|
||||
|
||||
if (-not $branchCreated) {
|
||||
$currentBranch = ''
|
||||
try { $currentBranch = (git rev-parse --abbrev-ref HEAD 2>$null).Trim() } catch {}
|
||||
$existingBranch = git branch --list $branchName 2>$null
|
||||
if ($existingBranch) {
|
||||
if ($AllowExistingBranch) {
|
||||
if ($currentBranch -eq $branchName) {
|
||||
# Already on the target branch
|
||||
} else {
|
||||
$switchBranchError = git checkout -q $branchName 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if ($switchBranchError) {
|
||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out.`n$($switchBranchError.Trim())"
|
||||
} else {
|
||||
Write-Error "Error: Branch '$branchName' exists but could not be checked out. Resolve any uncommitted changes or conflicts and try again."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} elseif ($Timestamp) {
|
||||
Write-Error "Error: Branch '$branchName' already exists. Rerun to get a new timestamp or use a different -ShortName."
|
||||
exit 1
|
||||
} else {
|
||||
Write-Error "Error: Branch '$branchName' already exists. Please use a different feature name or specify a different number with -Number."
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
if ($branchCreateError) {
|
||||
Write-Error "Error: Failed to create git branch '$branchName'.`n$($branchCreateError.Trim())"
|
||||
} else {
|
||||
Write-Error "Error: Failed to create git branch '$branchName'. Please check your git configuration and try again."
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($Json) {
|
||||
[Console]::Error.WriteLine("[specify] Warning: Git repository not detected; skipped branch creation for $branchName")
|
||||
} else {
|
||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch creation for $branchName"
|
||||
}
|
||||
}
|
||||
|
||||
$env:SPECIFY_FEATURE = $branchName
|
||||
}
|
||||
|
||||
if ($Json) {
|
||||
$obj = [PSCustomObject]@{
|
||||
BRANCH_NAME = $branchName
|
||||
FEATURE_NUM = $featureNum
|
||||
HAS_GIT = $hasGit
|
||||
}
|
||||
if ($DryRun) {
|
||||
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
|
||||
}
|
||||
$obj | ConvertTo-Json -Compress
|
||||
} else {
|
||||
Write-Output "BRANCH_NAME: $branchName"
|
||||
Write-Output "FEATURE_NUM: $featureNum"
|
||||
Write-Output "HAS_GIT: $hasGit"
|
||||
if (-not $DryRun) {
|
||||
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
||||
}
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Git-specific common functions for the git extension.
|
||||
# Extracted from scripts/powershell/common.ps1 — contains only git-specific
|
||||
# branch validation and detection logic.
|
||||
|
||||
function Test-HasGit {
|
||||
param([string]$RepoRoot = (Get-Location))
|
||||
try {
|
||||
if (-not (Test-Path (Join-Path $RepoRoot '.git'))) { return $false }
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) { return $false }
|
||||
git -C $RepoRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
|
||||
return ($LASTEXITCODE -eq 0)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function Get-SpecKitEffectiveBranchName {
|
||||
param([string]$Branch)
|
||||
if ($Branch -match '^([^/]+)/([^/]+)$') {
|
||||
return $Matches[2]
|
||||
}
|
||||
return $Branch
|
||||
}
|
||||
|
||||
function Test-FeatureBranch {
|
||||
param(
|
||||
[string]$Branch,
|
||||
[bool]$HasGit = $true
|
||||
)
|
||||
|
||||
# For non-git repos, we can't enforce branch naming but still provide output
|
||||
if (-not $HasGit) {
|
||||
Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
|
||||
return $true
|
||||
}
|
||||
|
||||
$raw = $Branch
|
||||
$Branch = Get-SpecKitEffectiveBranchName $raw
|
||||
|
||||
# Accept sequential prefix (3+ digits) but exclude malformed timestamps
|
||||
# Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
|
||||
$hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
|
||||
$isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
|
||||
if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
|
||||
[Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
|
||||
[Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
|
||||
return $false
|
||||
}
|
||||
return $true
|
||||
}
|
||||
@ -1,69 +0,0 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Git extension: initialize-repo.ps1
|
||||
# Initialize a Git repository with an initial commit.
|
||||
# Customizable — replace this script to add .gitignore templates,
|
||||
# default branch config, git-flow, LFS, signing, etc.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Find project root
|
||||
function Find-ProjectRoot {
|
||||
param([string]$StartDir)
|
||||
$current = Resolve-Path $StartDir
|
||||
while ($true) {
|
||||
foreach ($marker in @('.specify', '.git')) {
|
||||
if (Test-Path (Join-Path $current $marker)) {
|
||||
return $current
|
||||
}
|
||||
}
|
||||
$parent = Split-Path $current -Parent
|
||||
if ($parent -eq $current) { return $null }
|
||||
$current = $parent
|
||||
}
|
||||
}
|
||||
|
||||
$repoRoot = Find-ProjectRoot -StartDir $PSScriptRoot
|
||||
if (-not $repoRoot) { $repoRoot = Get-Location }
|
||||
Set-Location $repoRoot
|
||||
|
||||
# Read commit message from extension config, fall back to default
|
||||
$commitMsg = "[Spec Kit] Initial commit"
|
||||
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
||||
if (Test-Path $configFile) {
|
||||
foreach ($line in Get-Content $configFile) {
|
||||
if ($line -match '^init_commit_message:\s*(.+)$') {
|
||||
$val = $matches[1].Trim() -replace '^["'']' -replace '["'']$'
|
||||
if ($val) { $commitMsg = $val }
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Check if git is available
|
||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
||||
Write-Warning "[specify] Warning: Git not found; skipped repository initialization"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Check if already a git repo
|
||||
try {
|
||||
git rev-parse --is-inside-work-tree 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Warning "[specify] Git repository already initialized; skipping"
|
||||
exit 0
|
||||
}
|
||||
} catch { }
|
||||
|
||||
# Initialize
|
||||
try {
|
||||
$out = git init -q 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { throw "git init failed: $out" }
|
||||
$out = git add . 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { throw "git add failed: $out" }
|
||||
$out = git commit --allow-empty -q -m $commitMsg 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { throw "git commit failed: $out" }
|
||||
} catch {
|
||||
Write-Warning "[specify] Error: $_"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "✓ Git repository initialized"
|
||||
@ -1,10 +0,0 @@
|
||||
{
|
||||
"ai": "copilot",
|
||||
"branch_numbering": "sequential",
|
||||
"context_file": ".github/copilot-instructions.md",
|
||||
"here": true,
|
||||
"integration": "copilot",
|
||||
"preset": null,
|
||||
"script": "sh",
|
||||
"speckit_version": "0.7.4"
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
{
|
||||
"integration": "copilot",
|
||||
"version": "0.7.4"
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
{
|
||||
"integration": "copilot",
|
||||
"version": "0.7.4",
|
||||
"installed_at": "2026-04-22T21:58:02.962169+00:00",
|
||||
"files": {
|
||||
".github/agents/speckit.analyze.agent.md": "699032fdd49afe31d23c7191f3fe7bcb1d14b081fbc94c2287e6ba3a57574fda",
|
||||
".github/agents/speckit.checklist.agent.md": "d7d691689fe45427c868dcf18ade4df500f0c742a6c91923fefba405d6466dde",
|
||||
".github/agents/speckit.clarify.agent.md": "0cc766dcc5cab233ccdf3bc4cfb5759a6d7d1e13e29f611083046f818f5812bb",
|
||||
".github/agents/speckit.constitution.agent.md": "58d35eb026f56bb7364d91b8b0382d5dd1249ded6c1449a2b69546693afb85f7",
|
||||
".github/agents/speckit.implement.agent.md": "83628415c86ba487b3a083c7a2c0f016c9073abd02c1c7f4a30cff949b6602c0",
|
||||
".github/agents/speckit.plan.agent.md": "2ad128b81ccd8f5bfa78b3b43101f377dfddd8f800fa0856f85bf53b1489b783",
|
||||
".github/agents/speckit.specify.agent.md": "5bbb5270836cc9a3286ce3ed96a500f3d383a54abb06aa11b01a2d2f76dbf39b",
|
||||
".github/agents/speckit.tasks.agent.md": "a58886f29f75e1a14840007772ddd954742aafb3e03d9d1231bee033e6c1626b",
|
||||
".github/agents/speckit.taskstoissues.agent.md": "e84794f7a839126defb364ca815352c5c2b2d20db2d6da399fa53e4ddbb7b3ee",
|
||||
".github/prompts/speckit.analyze.prompt.md": "bb93dbbafa96d07b7cd07fc7061d8adb0c6b26cb772a52d0dce263b1ca2b9b77",
|
||||
".github/prompts/speckit.checklist.prompt.md": "c3aea7526c5cbfd8665acc9508ad5a9a3f71e91a63c36be7bed13a834c3a683c",
|
||||
".github/prompts/speckit.clarify.prompt.md": "ce79b3437ca918d46ac858eb4b8b44d3b0a02c563660c60d94c922a7b5d8d4f4",
|
||||
".github/prompts/speckit.constitution.prompt.md": "38f937279de14387601422ddfda48365debdbaf47b2d513527b8f6d8a27d499d",
|
||||
".github/prompts/speckit.implement.prompt.md": "5053a17fb9238338c63b898ee9c80b2cb4ad1a90c6071fe3748de76864ac6a80",
|
||||
".github/prompts/speckit.plan.prompt.md": "2098dae6bd9277335f31cb150b78bfb1de539c0491798e5cfe382c89ab0bcd0e",
|
||||
".github/prompts/speckit.specify.prompt.md": "7b2cc4dc6462da1c96df46bac4f60e53baba3097f4b24ac3f9b684194458aa98",
|
||||
".github/prompts/speckit.tasks.prompt.md": "88fc57c289f99d5e9d35c255f3e2683f73ecb0a5155dcb4d886f82f52b11841f",
|
||||
".github/prompts/speckit.taskstoissues.prompt.md": "2f9636d4f312a1470f000747cb62677fec0655d8b4e2357fa4fbf238965fa66d"
|
||||
}
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"integration": "speckit",
|
||||
"version": "0.7.4",
|
||||
"installed_at": "2026-04-22T21:58:02.965809+00:00",
|
||||
"files": {
|
||||
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3"
|
||||
}
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
# [PROJECT_NAME] Constitution
|
||||
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
|
||||
|
||||
## Core Principles
|
||||
|
||||
### [PRINCIPLE_1_NAME]
|
||||
<!-- Example: I. Library-First -->
|
||||
[PRINCIPLE_1_DESCRIPTION]
|
||||
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
|
||||
|
||||
### [PRINCIPLE_2_NAME]
|
||||
<!-- Example: II. CLI Interface -->
|
||||
[PRINCIPLE_2_DESCRIPTION]
|
||||
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
|
||||
|
||||
### [PRINCIPLE_3_NAME]
|
||||
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
|
||||
[PRINCIPLE_3_DESCRIPTION]
|
||||
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
|
||||
|
||||
### [PRINCIPLE_4_NAME]
|
||||
<!-- Example: IV. Integration Testing -->
|
||||
[PRINCIPLE_4_DESCRIPTION]
|
||||
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
|
||||
|
||||
### [PRINCIPLE_5_NAME]
|
||||
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
|
||||
[PRINCIPLE_5_DESCRIPTION]
|
||||
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
|
||||
|
||||
## [SECTION_2_NAME]
|
||||
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
|
||||
|
||||
[SECTION_2_CONTENT]
|
||||
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
|
||||
|
||||
## [SECTION_3_NAME]
|
||||
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
|
||||
|
||||
[SECTION_3_CONTENT]
|
||||
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
|
||||
|
||||
## Governance
|
||||
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
|
||||
|
||||
[GOVERNANCE_RULES]
|
||||
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
|
||||
|
||||
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
|
||||
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
|
||||
@ -1,63 +0,0 @@
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "speckit"
|
||||
name: "Full SDD Cycle"
|
||||
version: "1.0.0"
|
||||
author: "GitHub"
|
||||
description: "Runs specify → plan → tasks → implement with review gates"
|
||||
|
||||
requires:
|
||||
speckit_version: ">=0.7.2"
|
||||
integrations:
|
||||
any: ["copilot", "claude", "gemini"]
|
||||
|
||||
inputs:
|
||||
spec:
|
||||
type: string
|
||||
required: true
|
||||
prompt: "Describe what you want to build"
|
||||
integration:
|
||||
type: string
|
||||
default: "copilot"
|
||||
prompt: "Integration to use (e.g. claude, copilot, gemini)"
|
||||
scope:
|
||||
type: string
|
||||
default: "full"
|
||||
enum: ["full", "backend-only", "frontend-only"]
|
||||
|
||||
steps:
|
||||
- id: specify
|
||||
command: speckit.specify
|
||||
integration: "{{ inputs.integration }}"
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
|
||||
- id: review-spec
|
||||
type: gate
|
||||
message: "Review the generated spec before planning."
|
||||
options: [approve, reject]
|
||||
on_reject: abort
|
||||
|
||||
- id: plan
|
||||
command: speckit.plan
|
||||
integration: "{{ inputs.integration }}"
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
|
||||
- id: review-plan
|
||||
type: gate
|
||||
message: "Review the plan before generating tasks."
|
||||
options: [approve, reject]
|
||||
on_reject: abort
|
||||
|
||||
- id: tasks
|
||||
command: speckit.tasks
|
||||
integration: "{{ inputs.integration }}"
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
|
||||
- id: implement
|
||||
command: speckit.implement
|
||||
integration: "{{ inputs.integration }}"
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
@ -1,13 +0,0 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"workflows": {
|
||||
"speckit": {
|
||||
"name": "Full SDD Cycle",
|
||||
"version": "1.0.0",
|
||||
"description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates",
|
||||
"source": "bundled",
|
||||
"installed_at": "2026-04-22T21:58:03.039039+00:00",
|
||||
"updated_at": "2026-04-22T21:58:03.039046+00:00"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -14,7 +14,6 @@
|
||||
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;
|
||||
@ -353,14 +352,7 @@ private function reviewOutcomeBadgeIconColor(TenantReview $record): ?string
|
||||
|
||||
private function reviewOutcomeDescription(TenantReview $record): ?string
|
||||
{
|
||||
$primaryReason = $this->reviewOutcome($record)->primaryReason;
|
||||
$findingOutcomeSummary = $this->findingOutcomeSummary($record);
|
||||
|
||||
if ($findingOutcomeSummary === null) {
|
||||
return $primaryReason;
|
||||
}
|
||||
|
||||
return trim($primaryReason.' Terminal outcomes: '.$findingOutcomeSummary.'.');
|
||||
return $this->reviewOutcome($record)->primaryReason;
|
||||
}
|
||||
|
||||
private function reviewOutcomeNextStep(TenantReview $record): string
|
||||
@ -381,16 +373,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,6 @@
|
||||
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;
|
||||
@ -157,14 +156,6 @@ 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))
|
||||
@ -301,15 +292,9 @@ 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')
|
||||
->formatStateUsing(fn (?string $state): string => static::resolveReasonLabel($state) ?? '—')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('resolved_reason')->label('Resolved reason')->placeholder('—'),
|
||||
TextEntry::make('closed_at')->label('Closed at')->dateTime()->placeholder('—'),
|
||||
TextEntry::make('closed_reason')
|
||||
->label('Closed/risk reason')
|
||||
->formatStateUsing(fn (?string $state): string => static::closeReasonLabel($state) ?? '—')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('closed_reason')->label('Closed/risk reason')->placeholder('—'),
|
||||
TextEntry::make('closed_by_user_id')
|
||||
->label('Closed by')
|
||||
->formatStateUsing(fn (mixed $state, Finding $record): string => $record->closedByUser?->name ?? ($state ? 'User #'.$state : '—')),
|
||||
@ -741,7 +726,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::statusDescription($record)),
|
||||
->description(fn (Finding $record): string => static::primaryNarrative($record)),
|
||||
Tables\Columns\TextColumn::make('governance_validity')
|
||||
->label('Governance')
|
||||
->badge()
|
||||
@ -835,14 +820,6 @@ 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())
|
||||
@ -1115,20 +1092,16 @@ public static function table(Table $table): Table
|
||||
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('resolve_selected')
|
||||
->label(GovernanceActionCatalog::rule('resolve_finding')->canonicalLabel.' selected')
|
||||
->label('Resolve selected')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(GovernanceActionCatalog::rule('resolve_finding')->modalHeading)
|
||||
->modalDescription(GovernanceActionCatalog::rule('resolve_finding')->modalDescription)
|
||||
->form([
|
||||
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)
|
||||
Textarea::make('resolved_reason')
|
||||
->label('Resolution reason')
|
||||
->rows(3)
|
||||
->required()
|
||||
->selectablePlaceholder(false),
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -1172,7 +1145,7 @@ public static function table(Table $table): Table
|
||||
}
|
||||
}
|
||||
|
||||
$body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's')." pending verification.";
|
||||
$body = "Resolved {$resolvedCount} finding".($resolvedCount === 1 ? '' : 's').'.';
|
||||
if ($skippedCount > 0) {
|
||||
$body .= " Skipped {$skippedCount}.";
|
||||
}
|
||||
@ -1194,20 +1167,18 @@ public static function table(Table $table): Table
|
||||
|
||||
UiEnforcement::forBulkAction(
|
||||
BulkAction::make('close_selected')
|
||||
->label(GovernanceActionCatalog::rule('close_finding')->canonicalLabel.' selected')
|
||||
->label('Close selected')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('warning')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(GovernanceActionCatalog::rule('close_finding')->modalHeading)
|
||||
->modalDescription(GovernanceActionCatalog::rule('close_finding')->modalDescription)
|
||||
->form([
|
||||
Select::make('closed_reason')
|
||||
Textarea::make('closed_reason')
|
||||
->label('Close reason')
|
||||
->options(static::closeReasonOptions())
|
||||
->helperText('Use the canonical administrative closure outcome for this finding.')
|
||||
->native(false)
|
||||
->rows(3)
|
||||
->required()
|
||||
->selectablePlaceholder(false),
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (Collection $records, array $data, FindingWorkflowService $workflow): void {
|
||||
$tenant = static::resolveTenantContextForCurrentPanel();
|
||||
@ -1477,30 +1448,24 @@ 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($rule->canonicalLabel)
|
||||
->label('Resolve')
|
||||
->icon('heroicon-o-check-badge')
|
||||
->color('success')
|
||||
->visible(fn (Finding $record): bool => $record->hasOpenStatus())
|
||||
->requiresConfirmation()
|
||||
->modalHeading($rule->modalHeading)
|
||||
->modalDescription($rule->modalDescription)
|
||||
->form([
|
||||
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)
|
||||
Textarea::make('resolved_reason')
|
||||
->label('Resolution reason')
|
||||
->rows(3)
|
||||
->required()
|
||||
->selectablePlaceholder(false),
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow): void {
|
||||
static::runWorkflowMutation(
|
||||
record: $record,
|
||||
successTitle: $rule->successTitle,
|
||||
successTitle: 'Finding resolved',
|
||||
callback: fn (Finding $finding, Tenant $tenant, User $user): Finding => $workflow->resolve(
|
||||
$finding,
|
||||
$tenant,
|
||||
@ -1530,13 +1495,11 @@ public static function closeAction(): Actions\Action
|
||||
->modalHeading($rule->modalHeading)
|
||||
->modalDescription($rule->modalDescription)
|
||||
->form([
|
||||
Select::make('closed_reason')
|
||||
Textarea::make('closed_reason')
|
||||
->label('Close reason')
|
||||
->options(static::closeReasonOptions())
|
||||
->helperText('Use the canonical administrative closure outcome for this finding.')
|
||||
->native(false)
|
||||
->rows(3)
|
||||
->required()
|
||||
->selectablePlaceholder(false),
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
||||
static::runWorkflowMutation(
|
||||
@ -1731,17 +1694,12 @@ 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([
|
||||
Select::make('reopen_reason')
|
||||
Textarea::make('reopen_reason')
|
||||
->label('Reopen reason')
|
||||
->options(static::reopenReasonOptions())
|
||||
->helperText('Use the canonical reopen reason that best describes why this finding needs active workflow again.')
|
||||
->native(false)
|
||||
->rows(3)
|
||||
->required()
|
||||
->selectablePlaceholder(false),
|
||||
->maxLength(255),
|
||||
])
|
||||
->action(function (Finding $record, array $data, FindingWorkflowService $workflow) use ($rule): void {
|
||||
static::runWorkflowMutation(
|
||||
@ -2180,150 +2138,6 @@ 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,7 +18,6 @@
|
||||
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;
|
||||
@ -541,19 +540,12 @@ 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' => $highlights,
|
||||
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['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),
|
||||
@ -562,8 +554,6 @@ 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)],
|
||||
],
|
||||
];
|
||||
}
|
||||
@ -665,18 +655,4 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -345,12 +345,9 @@ private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $bac
|
||||
}
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'closed_at' => $backfillStartedAt,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
'closed_by_user_id' => null,
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => $backfillStartedAt,
|
||||
'resolved_reason' => 'consolidated_duplicate',
|
||||
'recurrence_key' => null,
|
||||
])->save();
|
||||
|
||||
|
||||
@ -325,12 +325,9 @@ private function consolidateDriftDuplicates(Tenant $tenant, CarbonImmutable $bac
|
||||
}
|
||||
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'resolved_at' => null,
|
||||
'resolved_reason' => null,
|
||||
'closed_at' => $backfillStartedAt,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
'closed_by_user_id' => null,
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => $backfillStartedAt,
|
||||
'resolved_reason' => 'consolidated_duplicate',
|
||||
'recurrence_key' => null,
|
||||
])->save();
|
||||
|
||||
|
||||
@ -47,32 +47,6 @@ 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';
|
||||
@ -186,113 +160,6 @@ 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) {
|
||||
|
||||
@ -4,9 +4,11 @@
|
||||
|
||||
namespace App\Notifications\Findings;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
@ -36,11 +38,20 @@ public function via(object $notifiable): array
|
||||
*/
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
$message = OperationUxPresenter::findingDatabaseNotificationMessage(
|
||||
$this->finding,
|
||||
$this->tenant,
|
||||
$this->event,
|
||||
);
|
||||
$message = FilamentNotification::make()
|
||||
->title($this->title())
|
||||
->body($this->body())
|
||||
->actions([
|
||||
Action::make('open_finding')
|
||||
->label('Open finding')
|
||||
->url(FindingResource::getUrl(
|
||||
'view',
|
||||
['record' => $this->finding],
|
||||
panel: 'tenant',
|
||||
tenant: $this->tenant,
|
||||
)),
|
||||
])
|
||||
->getDatabaseMessage();
|
||||
|
||||
$message['finding_event'] = [
|
||||
'event_type' => (string) ($this->event['event_type'] ?? ''),
|
||||
@ -54,4 +65,29 @@ public function toDatabase(object $notifiable): array
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
private function title(): string
|
||||
{
|
||||
$title = trim((string) ($this->event['title'] ?? 'Finding update'));
|
||||
|
||||
return $title !== '' ? $title : 'Finding update';
|
||||
}
|
||||
|
||||
private function body(): string
|
||||
{
|
||||
$body = trim((string) ($this->event['body'] ?? 'A finding needs follow-up.'));
|
||||
$recipientReason = $this->recipientReasonCopy((string) data_get($this->event, 'metadata.recipient_reason', ''));
|
||||
|
||||
return trim($body.' '.$recipientReason);
|
||||
}
|
||||
|
||||
private function recipientReasonCopy(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 => '',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,12 @@
|
||||
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;
|
||||
|
||||
@ -23,7 +27,25 @@ public function via(object $notifiable): array
|
||||
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
$message = OperationUxPresenter::terminalDatabaseNotificationMessage($this->run, $notifiable);
|
||||
$tenant = $this->run->tenant;
|
||||
$runUrl = match (true) {
|
||||
$notifiable instanceof PlatformUser => SystemOperationRunLinks::view($this->run),
|
||||
$tenant instanceof Tenant => OperationRunLinks::view($this->run, $tenant),
|
||||
default => OperationRunLinks::tenantlessView($this->run),
|
||||
};
|
||||
|
||||
$notification = OperationUxPresenter::terminalDatabaseNotification(
|
||||
run: $this->run,
|
||||
tenant: $tenant instanceof Tenant ? $tenant : null,
|
||||
);
|
||||
|
||||
$notification->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url($runUrl),
|
||||
]);
|
||||
|
||||
$message = $notification->getDatabaseMessage();
|
||||
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($this->run, 'notification');
|
||||
|
||||
if ($reasonEnvelope !== null) {
|
||||
|
||||
@ -3,7 +3,10 @@
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
@ -28,6 +31,31 @@ public function via(object $notifiable): array
|
||||
*/
|
||||
public function toDatabase(object $notifiable): array
|
||||
{
|
||||
return OperationUxPresenter::queuedDatabaseNotificationMessage($this->run, $notifiable);
|
||||
$tenant = $this->run->tenant;
|
||||
|
||||
$context = is_array($this->run->context) ? $this->run->context : [];
|
||||
$wizard = $context['wizard'] ?? null;
|
||||
|
||||
$isManagedTenantOnboardingWizardRun = is_array($wizard)
|
||||
&& ($wizard['flow'] ?? null) === 'managed_tenant_onboarding';
|
||||
|
||||
$operationLabel = OperationCatalog::label((string) $this->run->type);
|
||||
|
||||
$runUrl = match (true) {
|
||||
$isManagedTenantOnboardingWizardRun => OperationRunLinks::tenantlessView($this->run),
|
||||
$tenant instanceof Tenant => OperationRunLinks::view($this->run, $tenant),
|
||||
default => null,
|
||||
};
|
||||
|
||||
return FilamentNotification::make()
|
||||
->title("{$operationLabel} queued")
|
||||
->body('Queued for execution. Open the operation for progress and next steps.')
|
||||
->info()
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label(OperationRunLinks::openLabel())
|
||||
->url($runUrl),
|
||||
])
|
||||
->getDatabaseMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@ -213,12 +213,6 @@ 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,14 +8,12 @@
|
||||
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
|
||||
@ -35,7 +33,6 @@ 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(),
|
||||
@ -46,42 +43,10 @@ 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,
|
||||
);
|
||||
@ -113,8 +78,6 @@ 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(),
|
||||
];
|
||||
|
||||
|
||||
@ -857,7 +857,7 @@ private function evidenceSummary(array $references): array
|
||||
|
||||
private function findingRiskAcceptedReason(string $approvalReason): string
|
||||
{
|
||||
return Finding::CLOSE_REASON_ACCEPTED_RISK;
|
||||
return mb_substr($approvalReason, 0, 255);
|
||||
}
|
||||
|
||||
private function metadataDate(FindingException $exception, string $key): ?CarbonImmutable
|
||||
|
||||
@ -7,16 +7,11 @@
|
||||
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)) {
|
||||
@ -223,7 +218,11 @@ 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' => $this->historicalPrimaryNarrative($finding),
|
||||
'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.',
|
||||
},
|
||||
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.',
|
||||
@ -254,14 +253,8 @@ public function resolvePrimaryNextAction(Finding $finding, ?FindingException $ex
|
||||
};
|
||||
}
|
||||
|
||||
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.';
|
||||
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.';
|
||||
}
|
||||
|
||||
return match ($finding->responsibilityState()) {
|
||||
@ -347,33 +340,23 @@ private function renewalAwareDate(FindingException $exception, string $metadataK
|
||||
|
||||
private function resolvedHistoricalContext(Finding $finding): ?string
|
||||
{
|
||||
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.',
|
||||
$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.',
|
||||
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 ($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.',
|
||||
return match ((string) ($finding->closed_reason ?? '')) {
|
||||
'accepted_risk' => '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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,7 +14,6 @@
|
||||
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;
|
||||
@ -29,7 +28,6 @@ public function __construct(
|
||||
private readonly AuditLogger $auditLogger,
|
||||
private readonly CapabilityResolver $capabilityResolver,
|
||||
private readonly FindingNotificationService $findingNotificationService,
|
||||
private readonly FindingOutcomeSemantics $findingOutcomeSemantics,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -275,7 +273,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', Finding::manualResolveReasonKeys());
|
||||
$reason = $this->validatedReason($reason, 'resolved_reason');
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
return $this->mutateAndAudit(
|
||||
@ -301,7 +299,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', Finding::manualCloseReasonKeys());
|
||||
$reason = $this->validatedReason($reason, 'closed_reason');
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
return $this->mutateAndAudit(
|
||||
@ -344,7 +342,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', [Finding::CLOSE_REASON_ACCEPTED_RISK]);
|
||||
$reason = $this->validatedReason($reason, 'closed_reason');
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
return $this->mutateAndAudit(
|
||||
@ -378,7 +376,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', Finding::reopenReasonKeys());
|
||||
$reason = $this->validatedReason($reason, 'reopen_reason');
|
||||
$now = CarbonImmutable::now();
|
||||
$slaDays = $this->slaPolicy->daysForFinding($finding, $tenant);
|
||||
$dueAt = $this->slaPolicy->dueAtForSeverity((string) $finding->severity, $tenant, $now);
|
||||
@ -420,11 +418,11 @@ public function resolveBySystem(
|
||||
): Finding {
|
||||
$this->assertFindingOwnedByTenant($finding, $tenant);
|
||||
|
||||
if (! $finding->hasOpenStatus() && (string) $finding->status !== Finding::STATUS_RESOLVED) {
|
||||
throw new InvalidArgumentException('Only open or manually resolved findings can be system-cleared.');
|
||||
if (! $finding->hasOpenStatus()) {
|
||||
throw new InvalidArgumentException('Only open findings can be resolved.');
|
||||
}
|
||||
|
||||
$reason = $this->validatedReason($reason, 'resolved_reason', Finding::systemResolveReasonKeys());
|
||||
$reason = $this->validatedReason($reason, 'resolved_reason');
|
||||
|
||||
return $this->mutateAndAudit(
|
||||
finding: $finding,
|
||||
@ -458,7 +456,6 @@ public function reopenBySystem(
|
||||
CarbonImmutable $reopenedAt,
|
||||
?int $operationRunId = null,
|
||||
?callable $mutate = null,
|
||||
?string $reason = null,
|
||||
): Finding {
|
||||
$this->assertFindingOwnedByTenant($finding, $tenant);
|
||||
|
||||
@ -466,11 +463,6 @@ 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);
|
||||
|
||||
@ -482,7 +474,6 @@ public function reopenBySystem(
|
||||
context: [
|
||||
'metadata' => [
|
||||
'reopened_at' => $reopenedAt->toIso8601String(),
|
||||
'reopened_reason' => $reason,
|
||||
'sla_days' => $slaDays,
|
||||
'due_at' => $dueAt->toIso8601String(),
|
||||
'system_origin' => true,
|
||||
@ -583,10 +574,7 @@ private function assertTenantMemberOrNull(Tenant $tenant, ?int $userId, string $
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $allowedReasons
|
||||
*/
|
||||
private function validatedReason(string $reason, string $field, array $allowedReasons): string
|
||||
private function validatedReason(string $reason, string $field): string
|
||||
{
|
||||
$reason = trim($reason);
|
||||
|
||||
@ -598,14 +586,6 @@ private function validatedReason(string $reason, string $field, array $allowedRe
|
||||
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;
|
||||
}
|
||||
|
||||
@ -657,17 +637,12 @@ 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),
|
||||
]);
|
||||
|
||||
@ -738,7 +713,6 @@ 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,12 +83,6 @@ 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']
|
||||
: [],
|
||||
@ -174,12 +168,6 @@ 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,12 +59,6 @@ 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,17 +6,12 @@
|
||||
|
||||
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>>
|
||||
*/
|
||||
@ -52,8 +47,6 @@ 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);
|
||||
@ -62,11 +55,9 @@ 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),
|
||||
@ -90,8 +81,6 @@ 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,7 +13,6 @@
|
||||
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;
|
||||
@ -143,22 +142,6 @@ 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>
|
||||
*/
|
||||
|
||||
@ -1,203 +0,0 @@
|
||||
<?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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -4,11 +4,7 @@
|
||||
|
||||
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;
|
||||
@ -16,13 +12,11 @@
|
||||
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
|
||||
@ -87,48 +81,6 @@ 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.
|
||||
*
|
||||
@ -137,40 +89,44 @@ public static function queuedDatabaseNotificationMessage(OperationRun $run, obje
|
||||
*/
|
||||
public static function terminalDatabaseNotification(OperationRun $run, ?Tenant $tenant = null): FilamentNotification
|
||||
{
|
||||
$payload = self::terminalNotificationPayload($run);
|
||||
$actionUrl = $tenant instanceof Tenant
|
||||
? OperationRunUrl::view($run, $tenant)
|
||||
: OperationRunLinks::tenantlessView($run);
|
||||
$operationLabel = OperationCatalog::label((string) $run->type);
|
||||
$presentation = self::terminalPresentation($run);
|
||||
$bodyLines = [$presentation['body']];
|
||||
|
||||
return self::makeDatabaseNotification(
|
||||
title: $payload['title'],
|
||||
body: $payload['body'],
|
||||
status: $payload['status'],
|
||||
actionName: 'view_run',
|
||||
actionLabel: OperationRunLinks::openLabel(),
|
||||
actionUrl: $actionUrl,
|
||||
supportingLines: $payload['supportingLines'],
|
||||
);
|
||||
}
|
||||
$failureMessage = self::surfaceFailureDetail($run);
|
||||
if ($failureMessage !== null) {
|
||||
$bodyLines[] = $failureMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function terminalDatabaseNotificationMessage(OperationRun $run, object $notifiable): array
|
||||
{
|
||||
$payload = self::terminalNotificationPayload($run);
|
||||
$primaryAction = self::operationRunPrimaryAction($run, $notifiable);
|
||||
$guidance = self::surfaceGuidance($run);
|
||||
if ($guidance !== null) {
|
||||
$bodyLines[] = $guidance;
|
||||
}
|
||||
|
||||
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'],
|
||||
);
|
||||
$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;
|
||||
}
|
||||
|
||||
public static function surfaceGuidance(OperationRun $run): ?string
|
||||
@ -389,59 +345,6 @@ private static function buildLifecycleAttentionSummary(OperationRun $run): ?stri
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $event
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function findingNotificationSupportingLines(array $event): array
|
||||
{
|
||||
$recipientReason = self::findingRecipientReasonCopy((string) data_get($event, 'metadata.recipient_reason', ''));
|
||||
|
||||
return $recipientReason !== '' ? [$recipientReason] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $event
|
||||
*/
|
||||
private static function findingNotificationTitle(array $event): string
|
||||
{
|
||||
$title = trim((string) ($event['title'] ?? 'Finding update'));
|
||||
|
||||
return $title !== '' ? $title : 'Finding update';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $event
|
||||
*/
|
||||
private static function findingNotificationBody(array $event): string
|
||||
{
|
||||
$body = trim((string) ($event['body'] ?? 'A finding needs follow-up.'));
|
||||
|
||||
return $body !== '' ? $body : 'A finding needs follow-up.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $event
|
||||
*/
|
||||
private static function findingNotificationStatus(array $event): string
|
||||
{
|
||||
return match ((string) ($event['event_type'] ?? '')) {
|
||||
AlertRule::EVENT_FINDINGS_DUE_SOON => 'warning',
|
||||
AlertRule::EVENT_FINDINGS_OVERDUE => 'danger',
|
||||
default => 'info',
|
||||
};
|
||||
}
|
||||
|
||||
private static function findingRecipientReasonCopy(string $reason): string
|
||||
{
|
||||
return match ($reason) {
|
||||
'new_assignee' => 'You are the new assignee.',
|
||||
'current_assignee' => 'You are the current assignee.',
|
||||
'current_owner' => 'You are the accountable owner.',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
public static function governanceOperatorExplanation(OperationRun $run): ?OperatorExplanationPattern
|
||||
{
|
||||
return self::resolveGovernanceOperatorExplanation($run);
|
||||
@ -474,7 +377,7 @@ private static function terminalPresentation(OperationRun $run): array
|
||||
if ($freshnessState->isReconciledFailed()) {
|
||||
return [
|
||||
'titleSuffix' => 'was automatically reconciled',
|
||||
'body' => 'Automatically reconciled after infrastructure failure.',
|
||||
'body' => $reasonEnvelope?->operatorLabel ?? 'Automatically reconciled after infrastructure failure.',
|
||||
'status' => 'danger',
|
||||
];
|
||||
}
|
||||
@ -492,198 +395,17 @@ private static function terminalPresentation(OperationRun $run): array
|
||||
],
|
||||
'blocked' => [
|
||||
'titleSuffix' => 'blocked by prerequisite',
|
||||
'body' => 'Blocked by prerequisite.',
|
||||
'body' => $reasonEnvelope?->operatorLabel ?? 'Blocked by prerequisite.',
|
||||
'status' => 'warning',
|
||||
],
|
||||
default => [
|
||||
'titleSuffix' => 'execution failed',
|
||||
'body' => 'Execution failed.',
|
||||
'body' => $reasonEnvelope?->operatorLabel ?? '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' => ['resolve_finding', 'close_finding', 'reopen_finding'],
|
||||
'defaultActionOrder' => ['close_finding', 'reopen_finding'],
|
||||
'supportsDocumentedDeviation' => false,
|
||||
'defaultMutationScopeSource' => 'finding lifecycle',
|
||||
],
|
||||
@ -260,20 +260,6 @@ 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',
|
||||
@ -282,7 +268,7 @@ public static function rules(): array
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Close',
|
||||
modalHeading: 'Close finding',
|
||||
modalDescription: 'Close this finding for the current tenant. TenantPilot records a canonical administrative closure reason such as false positive, duplicate, or no longer applicable.',
|
||||
modalDescription: 'Close this finding for the current tenant. TenantPilot records the closing rationale and closes the finding lifecycle.',
|
||||
successTitle: 'Finding closed',
|
||||
auditVerb: 'close finding',
|
||||
serviceOwner: 'FindingWorkflowService',
|
||||
@ -296,7 +282,7 @@ public static function rules(): array
|
||||
dangerPolicy: 'none',
|
||||
canonicalLabel: 'Reopen',
|
||||
modalHeading: 'Reopen finding',
|
||||
modalDescription: 'Reopen this terminal finding for the current tenant. TenantPilot records a canonical reopen reason and recalculates due attention.',
|
||||
modalDescription: 'Reopen this closed finding for the current tenant. TenantPilot records why the lifecycle is being reopened and recalculates due attention.',
|
||||
successTitle: 'Finding reopened',
|
||||
auditVerb: 'reopen finding',
|
||||
serviceOwner: 'FindingWorkflowService',
|
||||
@ -503,17 +489,6 @@ 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',
|
||||
|
||||
@ -120,16 +120,7 @@ public function resolved(): static
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => now(),
|
||||
'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,
|
||||
'resolved_reason' => 'permission_granted',
|
||||
]);
|
||||
}
|
||||
|
||||
@ -185,7 +176,7 @@ public function closed(): static
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'closed_at' => now(),
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
'closed_reason' => 'duplicate',
|
||||
]);
|
||||
}
|
||||
|
||||
@ -197,7 +188,7 @@ public function riskAccepted(): static
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||
'closed_at' => now(),
|
||||
'closed_reason' => Finding::CLOSE_REASON_ACCEPTED_RISK,
|
||||
'closed_reason' => 'accepted_risk',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -1100,7 +1100,7 @@
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => now()->subMinute(),
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
'resolved_reason' => 'manually_resolved',
|
||||
])->save();
|
||||
|
||||
$firstRun->update(['completed_at' => now()->subMinute()]);
|
||||
|
||||
@ -63,52 +63,3 @@
|
||||
->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, 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);
|
||||
$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');
|
||||
|
||||
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(Finding::CLOSE_REASON_DUPLICATE)
|
||||
->and(data_get($closedAudit->metadata, 'closed_reason'))->toBe('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(Finding::REOPEN_REASON_MANUAL_REASSESSMENT);
|
||||
->and(data_get($reopenedAudit->metadata, 'reopened_reason'))->toBe('The issue recurred after validation.');
|
||||
});
|
||||
|
||||
it('deduplicates repeated finding audit writes for the same successful mutation payload', function (): void {
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
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);
|
||||
@ -25,7 +24,7 @@
|
||||
$service = app(FindingWorkflowService::class);
|
||||
|
||||
$service->triage($finding, $tenant, $user);
|
||||
$service->resolve($finding->refresh(), $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
|
||||
$service->resolve($finding->refresh(), $tenant, $user, 'fixed');
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('tenant_id', (int) $tenant->getKey())
|
||||
@ -41,9 +40,7 @@
|
||||
->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(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, 'resolved_reason'))->toBe('fixed')
|
||||
->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(Finding::RESOLVE_REASON_NO_LONGER_DRIFTING);
|
||||
->and(data_get($audit->metadata, 'resolved_reason'))->toBe('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(Finding::RESOLVE_REASON_PERMISSION_GRANTED)
|
||||
->and(data_get($resolvedAudit->metadata, 'resolved_reason'))->toBe('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' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
'resolved_reason' => 'fixed',
|
||||
])->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' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
'resolved_reason' => 'fixed',
|
||||
'recurrence_key' => null,
|
||||
'evidence_jsonb' => $evidence,
|
||||
'first_seen_at' => null,
|
||||
@ -126,11 +126,9 @@
|
||||
->and($open->status)->toBe(Finding::STATUS_NEW);
|
||||
|
||||
expect($duplicate->recurrence_key)->toBeNull()
|
||||
->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');
|
||||
->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');
|
||||
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
@ -88,7 +88,7 @@ function findingBulkAuditResourceIds(int $tenantId, string $action): array
|
||||
|
||||
$component
|
||||
->callTableBulkAction('resolve_selected', $resolveFindings, data: [
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
'resolved_reason' => 'fixed',
|
||||
])
|
||||
->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(Finding::RESOLVE_REASON_REMEDIATED)
|
||||
->and($finding->resolved_reason)->toBe('fixed')
|
||||
->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' => Finding::CLOSE_REASON_NO_LONGER_APPLICABLE,
|
||||
'closed_reason' => 'not 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(Finding::CLOSE_REASON_NO_LONGER_APPLICABLE)
|
||||
->and($finding->closed_reason)->toBe('not applicable')
|
||||
->and($finding->closed_at)->not->toBeNull()
|
||||
->and($finding->closed_by_user_id)->not->toBeNull();
|
||||
});
|
||||
|
||||
@ -1,145 +0,0 @@
|
||||
<?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,7 +8,6 @@
|
||||
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;
|
||||
|
||||
@ -214,7 +213,7 @@ function baselineCompareRbacDriftItem(
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'),
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
'resolved_reason' => 'fixed',
|
||||
])->save();
|
||||
|
||||
$observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z');
|
||||
@ -260,11 +259,6 @@ 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 {
|
||||
@ -314,7 +308,7 @@ function baselineCompareRbacDriftItem(
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'closed_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'),
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
'closed_reason' => 'accepted',
|
||||
])->save();
|
||||
|
||||
$observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z');
|
||||
@ -402,7 +396,7 @@ function baselineCompareRbacDriftItem(
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => CarbonImmutable::parse('2026-02-26T00:00:00Z'),
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
'resolved_reason' => 'manual',
|
||||
])->save();
|
||||
|
||||
$observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z');
|
||||
@ -491,7 +485,7 @@ function baselineCompareRbacDriftItem(
|
||||
$finding->forceFill([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => CarbonImmutable::parse('2026-02-22T00:00:00Z'),
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING,
|
||||
'resolved_reason' => 'fixed',
|
||||
])->save();
|
||||
|
||||
$observedAt2 = CarbonImmutable::parse('2026-02-25T00:00:00Z');
|
||||
@ -529,9 +523,4 @@ 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);
|
||||
});
|
||||
|
||||
@ -7,10 +7,8 @@
|
||||
use App\Models\Finding;
|
||||
use App\Models\FindingException;
|
||||
use App\Models\User;
|
||||
use App\Services\Evidence\Sources\FindingsSummarySource;
|
||||
use App\Services\Findings\FindingExceptionService;
|
||||
use App\Services\Findings\FindingRiskGovernanceResolver;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
@ -173,24 +171,3 @@
|
||||
expect(app(FindingRiskGovernanceResolver::class)->resolveFindingState($finding->fresh('findingException')))
|
||||
->toBe('ungoverned');
|
||||
});
|
||||
|
||||
it('keeps accepted risk in a separate reporting bucket from administrative closures', function (): void {
|
||||
[, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||
'closed_reason' => Finding::CLOSE_REASON_ACCEPTED_RISK,
|
||||
]);
|
||||
|
||||
Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
]);
|
||||
|
||||
$summary = app(FindingsSummarySource::class)->collect($tenant)['summary_payload'] ?? [];
|
||||
|
||||
expect(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED))->toBe(1)
|
||||
->and(data_get($summary, 'outcome_counts.'.FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE))->toBe(1)
|
||||
->and(data_get($summary, 'report_bucket_counts.accepted_risk'))->toBe(1)
|
||||
->and(data_get($summary, 'report_bucket_counts.administrative_closure'))->toBe(1);
|
||||
});
|
||||
|
||||
@ -100,7 +100,7 @@ function invokeConcurrencyBaselineCompareUpsertFindings(
|
||||
->firstOrFail();
|
||||
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-03-18T10:00:00Z'));
|
||||
app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
|
||||
app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, 'human-won');
|
||||
|
||||
$run2 = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
@ -156,7 +156,7 @@ function invokeConcurrencyBaselineCompareUpsertFindings(
|
||||
->firstOrFail();
|
||||
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-03-18T09:00:00Z'));
|
||||
app(FindingWorkflowService::class)->close($finding, $tenant, $user, Finding::CLOSE_REASON_DUPLICATE);
|
||||
app(FindingWorkflowService::class)->close($finding, $tenant, $user, 'accepted');
|
||||
|
||||
$run2 = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
|
||||
@ -47,19 +47,19 @@
|
||||
|
||||
$component
|
||||
->callTableAction('resolve', $finding, [
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
'resolved_reason' => 'patched',
|
||||
])
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
$finding->refresh();
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED)
|
||||
->and($finding->resolved_reason)->toBe('patched')
|
||||
->and($finding->resolved_at)->not->toBeNull();
|
||||
|
||||
$component
|
||||
->filterTable('open', false)
|
||||
->callTableAction('reopen', $finding, [
|
||||
'reopen_reason' => Finding::REOPEN_REASON_MANUAL_REASSESSMENT,
|
||||
'reopen_reason' => 'The issue recurred in a later scan.',
|
||||
])
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
@ -86,7 +86,7 @@
|
||||
|
||||
$component
|
||||
->callTableAction('close', $closeFinding, [
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
'closed_reason' => 'duplicate ticket',
|
||||
])
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
@ -100,7 +100,7 @@
|
||||
->assertHasNoTableActionErrors();
|
||||
|
||||
expect($closeFinding->refresh()->status)->toBe(Finding::STATUS_CLOSED)
|
||||
->and($closeFinding->closed_reason)->toBe(Finding::CLOSE_REASON_DUPLICATE);
|
||||
->and($closeFinding->closed_reason)->toBe('duplicate ticket');
|
||||
|
||||
$exception = FindingException::query()
|
||||
->where('finding_id', (int) $exceptionFinding->getKey())
|
||||
|
||||
@ -6,15 +6,9 @@
|
||||
use App\Models\User;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use App\Support\Audit\AuditActionId;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
it('enforces the canonical transition matrix for service-driven status changes', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
@ -31,24 +25,17 @@
|
||||
expect($inProgressFinding->status)->toBe(Finding::STATUS_IN_PROGRESS)
|
||||
->and($this->latestFindingAudit($inProgressFinding, AuditActionId::FindingInProgress))->not->toBeNull();
|
||||
|
||||
$resolvedFinding = $service->resolve($inProgressFinding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
|
||||
$resolvedAudit = $this->latestFindingAudit($resolvedFinding, AuditActionId::FindingResolved);
|
||||
$resolvedFinding = $service->resolve($inProgressFinding, $tenant, $user, 'patched');
|
||||
|
||||
expect($resolvedFinding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($resolvedFinding->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED)
|
||||
->and($resolvedAudit)->not->toBeNull()
|
||||
->and(data_get($resolvedAudit?->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION)
|
||||
->and(data_get($resolvedAudit?->metadata, 'verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_PENDING)
|
||||
->and(data_get($resolvedAudit?->metadata, 'report_bucket'))->toBe('remediation_pending_verification');
|
||||
->and($resolvedFinding->resolved_reason)->toBe('patched')
|
||||
->and($this->latestFindingAudit($resolvedFinding, AuditActionId::FindingResolved))->not->toBeNull();
|
||||
|
||||
$reopenedFinding = $service->reopen($resolvedFinding, $tenant, $user, Finding::REOPEN_REASON_MANUAL_REASSESSMENT);
|
||||
$reopenedAudit = $this->latestFindingAudit($reopenedFinding, AuditActionId::FindingReopened);
|
||||
$reopenedFinding = $service->reopen($resolvedFinding, $tenant, $user, 'The issue recurred after remediation.');
|
||||
|
||||
expect($reopenedFinding->status)->toBe(Finding::STATUS_REOPENED)
|
||||
->and($reopenedFinding->reopened_at)->not->toBeNull()
|
||||
->and($reopenedAudit)->not->toBeNull()
|
||||
->and(data_get($reopenedAudit?->metadata, 'reopened_reason'))->toBe(Finding::REOPEN_REASON_MANUAL_REASSESSMENT)
|
||||
->and(data_get($reopenedAudit?->metadata, 'terminal_outcome_key'))->toBeNull();
|
||||
->and($this->latestFindingAudit($reopenedFinding, AuditActionId::FindingReopened))->not->toBeNull();
|
||||
|
||||
expect(fn () => $service->startProgress($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user))
|
||||
->toThrow(\InvalidArgumentException::class, 'Finding cannot be moved to in-progress from the current status.');
|
||||
@ -131,118 +118,14 @@
|
||||
->toThrow(\InvalidArgumentException::class, 'closed_reason is required.');
|
||||
});
|
||||
|
||||
it('enforces canonical manual reason keys for terminal workflow mutations', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$service = app(FindingWorkflowService::class);
|
||||
|
||||
expect(fn () => $service->resolve($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, 'patched'))
|
||||
->toThrow(\InvalidArgumentException::class, 'resolved_reason must be one of: remediated.');
|
||||
|
||||
expect(fn () => $service->close($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, 'not applicable'))
|
||||
->toThrow(\InvalidArgumentException::class, 'closed_reason must be one of: false_positive, duplicate, no_longer_applicable.');
|
||||
|
||||
expect(fn () => $service->reopen($this->makeFindingForWorkflow($tenant, Finding::STATUS_RESOLVED), $tenant, $user, 'please re-open'))
|
||||
->toThrow(\InvalidArgumentException::class, 'reopen_reason must be one of: recurred_after_resolution, verification_failed, manual_reassessment.');
|
||||
|
||||
expect(fn () => $service->riskAccept($this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW), $tenant, $user, 'accepted'))
|
||||
->toThrow(\InvalidArgumentException::class, 'closed_reason must be one of: accepted_risk.');
|
||||
});
|
||||
|
||||
it('records canonical close and risk-accept outcome metadata', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$service = app(FindingWorkflowService::class);
|
||||
|
||||
$closed = $service->close(
|
||||
$this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW),
|
||||
$tenant,
|
||||
$user,
|
||||
Finding::CLOSE_REASON_DUPLICATE,
|
||||
);
|
||||
$closedAudit = $this->latestFindingAudit($closed, AuditActionId::FindingClosed);
|
||||
|
||||
expect($closed->status)->toBe(Finding::STATUS_CLOSED)
|
||||
->and($closed->closed_reason)->toBe(Finding::CLOSE_REASON_DUPLICATE)
|
||||
->and(data_get($closedAudit?->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE)
|
||||
->and(data_get($closedAudit?->metadata, 'report_bucket'))->toBe('administrative_closure');
|
||||
|
||||
$riskAccepted = $service->riskAccept(
|
||||
$this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW),
|
||||
$tenant,
|
||||
$user,
|
||||
Finding::CLOSE_REASON_ACCEPTED_RISK,
|
||||
);
|
||||
$riskAudit = $this->latestFindingAudit($riskAccepted, AuditActionId::FindingRiskAccepted);
|
||||
|
||||
expect($riskAccepted->status)->toBe(Finding::STATUS_RISK_ACCEPTED)
|
||||
->and($riskAccepted->closed_reason)->toBe(Finding::CLOSE_REASON_ACCEPTED_RISK)
|
||||
->and(data_get($riskAudit?->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED)
|
||||
->and(data_get($riskAudit?->metadata, 'report_bucket'))->toBe('accepted_risk');
|
||||
});
|
||||
|
||||
it('distinguishes verified clear from manual resolution in system transitions', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
CarbonImmutable::setTestNow('2026-04-23T10:00:00Z');
|
||||
|
||||
$service = app(FindingWorkflowService::class);
|
||||
|
||||
$manualResolved = $service->resolve(
|
||||
$this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW),
|
||||
$tenant,
|
||||
$user,
|
||||
Finding::RESOLVE_REASON_REMEDIATED,
|
||||
);
|
||||
|
||||
$verified = $service->resolveBySystem(
|
||||
finding: $manualResolved,
|
||||
tenant: $tenant,
|
||||
reason: Finding::RESOLVE_REASON_NO_LONGER_DRIFTING,
|
||||
resolvedAt: CarbonImmutable::parse('2026-04-23T11:00:00Z'),
|
||||
);
|
||||
$verifiedAudit = $this->latestFindingAudit($verified, AuditActionId::FindingResolved);
|
||||
|
||||
expect($verified->resolved_reason)->toBe(Finding::RESOLVE_REASON_NO_LONGER_DRIFTING)
|
||||
->and(data_get($verifiedAudit?->metadata, 'system_origin'))->toBeTrue()
|
||||
->and(data_get($verifiedAudit?->metadata, 'terminal_outcome_key'))->toBe(FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED)
|
||||
->and(data_get($verifiedAudit?->metadata, 'verification_state'))->toBe(FindingOutcomeSemantics::VERIFICATION_VERIFIED)
|
||||
->and(data_get($verifiedAudit?->metadata, 'report_bucket'))->toBe('remediation_verified');
|
||||
|
||||
$verificationFailed = $service->reopenBySystem(
|
||||
finding: $service->resolve(
|
||||
$this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW),
|
||||
$tenant,
|
||||
$user,
|
||||
Finding::RESOLVE_REASON_REMEDIATED,
|
||||
),
|
||||
tenant: $tenant,
|
||||
reopenedAt: CarbonImmutable::parse('2026-04-23T12:00:00Z'),
|
||||
);
|
||||
$verificationFailedAudit = $this->latestFindingAudit($verificationFailed, AuditActionId::FindingReopened);
|
||||
|
||||
expect(data_get($verificationFailedAudit?->metadata, 'reopened_reason'))
|
||||
->toBe(Finding::REOPEN_REASON_VERIFICATION_FAILED);
|
||||
|
||||
$recurred = $service->reopenBySystem(
|
||||
finding: $verified,
|
||||
tenant: $tenant,
|
||||
reopenedAt: CarbonImmutable::parse('2026-04-23T13:00:00Z'),
|
||||
);
|
||||
$recurredAudit = $this->latestFindingAudit($recurred, AuditActionId::FindingReopened);
|
||||
|
||||
expect(data_get($recurredAudit?->metadata, 'reopened_reason'))
|
||||
->toBe(Finding::REOPEN_REASON_RECURRED_AFTER_RESOLUTION);
|
||||
});
|
||||
|
||||
it('returns 403 for in-scope members without the required workflow capability', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
[$readonly] = createUserWithTenant(tenant: $tenant, role: 'readonly');
|
||||
$finding = $this->makeFindingForWorkflow($tenant, Finding::STATUS_NEW);
|
||||
|
||||
expect(fn () => app(FindingWorkflowService::class)->resolve($finding, $tenant, $readonly, Finding::RESOLVE_REASON_REMEDIATED))
|
||||
expect(fn () => app(FindingWorkflowService::class)->resolve($finding, $tenant, $readonly, 'patched'))
|
||||
->toThrow(AuthorizationException::class);
|
||||
|
||||
expect(app(FindingWorkflowService::class)->resolve($finding, $tenant, $owner, Finding::RESOLVE_REASON_REMEDIATED)->status)
|
||||
expect(app(FindingWorkflowService::class)->resolve($finding, $tenant, $owner, 'patched')->status)
|
||||
->toBe(Finding::STATUS_RESOLVED);
|
||||
});
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
use App\Models\Finding;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@ -22,7 +21,7 @@
|
||||
$resolvedFinding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => now(),
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
'resolved_reason' => 'fixed',
|
||||
]);
|
||||
|
||||
Livewire::test(ViewFinding::class, ['record' => $newFinding->getKey()])
|
||||
@ -39,10 +38,8 @@
|
||||
->assertActionVisible('reopen')
|
||||
->mountAction('reopen')
|
||||
->assertActionMounted('reopen')
|
||||
->assertFormFieldExists('reopen_reason', function (Select $field): bool {
|
||||
return $field->getLabel() === 'Reopen reason'
|
||||
&& array_keys($field->getOptions()) === Finding::reopenReasonKeys();
|
||||
});
|
||||
->callMountedAction()
|
||||
->assertHasActionErrors(['reopen_reason']);
|
||||
});
|
||||
|
||||
it('executes workflow actions from view header and supports assignment to tenant members only', function (): void {
|
||||
@ -68,7 +65,7 @@
|
||||
])
|
||||
->assertHasNoActionErrors()
|
||||
->callAction('resolve', [
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
'resolved_reason' => 'handled in queue',
|
||||
])
|
||||
->assertHasNoActionErrors();
|
||||
|
||||
@ -80,13 +77,12 @@
|
||||
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
|
||||
->mountAction('reopen')
|
||||
->assertActionMounted('reopen')
|
||||
->assertFormFieldExists('reopen_reason', function (Select $field): bool {
|
||||
return array_keys($field->getOptions()) === Finding::reopenReasonKeys();
|
||||
});
|
||||
->callMountedAction()
|
||||
->assertHasActionErrors(['reopen_reason']);
|
||||
|
||||
Livewire::test(ViewFinding::class, ['record' => $finding->getKey()])
|
||||
->callAction('reopen', [
|
||||
'reopen_reason' => Finding::REOPEN_REASON_MANUAL_REASSESSMENT,
|
||||
'reopen_reason' => 'The finding recurred after remediation.',
|
||||
])
|
||||
->assertHasNoActionErrors()
|
||||
->callAction('assign', [
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
use App\Filament\Resources\FindingResource\Pages\ListFindings;
|
||||
use App\Models\Finding;
|
||||
use App\Models\User;
|
||||
use App\Support\Findings\FindingOutcomeSemantics;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
@ -93,7 +92,7 @@ function findingFilterIndicatorLabels($component): array
|
||||
$historical = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => now()->subDay(),
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING,
|
||||
'resolved_reason' => 'no_longer_drifting',
|
||||
]);
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
@ -110,59 +109,6 @@ function findingFilterIndicatorLabels($component): array
|
||||
->assertCanNotSeeTableRecords([$active, $healthyAccepted, $historical]);
|
||||
});
|
||||
|
||||
it('filters findings by canonical terminal outcome and verification state', function (): void {
|
||||
[, $tenant] = actingAsFindingsManagerForFilters();
|
||||
|
||||
$pendingVerification = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
]);
|
||||
|
||||
$verifiedCleared = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_NO_LONGER_DRIFTING,
|
||||
]);
|
||||
|
||||
$closedDuplicate = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_CLOSED,
|
||||
'closed_reason' => Finding::CLOSE_REASON_DUPLICATE,
|
||||
]);
|
||||
|
||||
$riskAccepted = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_RISK_ACCEPTED,
|
||||
'closed_reason' => Finding::CLOSE_REASON_ACCEPTED_RISK,
|
||||
]);
|
||||
|
||||
$openFinding = Finding::factory()->for($tenant)->create([
|
||||
'status' => Finding::STATUS_NEW,
|
||||
]);
|
||||
|
||||
Livewire::test(ListFindings::class)
|
||||
->filterTable('terminal_outcome', FindingOutcomeSemantics::OUTCOME_RESOLVED_PENDING_VERIFICATION)
|
||||
->assertCanSeeTableRecords([$pendingVerification])
|
||||
->assertCanNotSeeTableRecords([$verifiedCleared, $closedDuplicate, $riskAccepted, $openFinding])
|
||||
->removeTableFilters()
|
||||
->filterTable('terminal_outcome', FindingOutcomeSemantics::OUTCOME_VERIFIED_CLEARED)
|
||||
->assertCanSeeTableRecords([$verifiedCleared])
|
||||
->assertCanNotSeeTableRecords([$pendingVerification, $closedDuplicate, $riskAccepted, $openFinding])
|
||||
->removeTableFilters()
|
||||
->filterTable('terminal_outcome', FindingOutcomeSemantics::OUTCOME_CLOSED_DUPLICATE)
|
||||
->assertCanSeeTableRecords([$closedDuplicate])
|
||||
->assertCanNotSeeTableRecords([$pendingVerification, $verifiedCleared, $riskAccepted, $openFinding])
|
||||
->removeTableFilters()
|
||||
->filterTable('terminal_outcome', FindingOutcomeSemantics::OUTCOME_RISK_ACCEPTED)
|
||||
->assertCanSeeTableRecords([$riskAccepted])
|
||||
->assertCanNotSeeTableRecords([$pendingVerification, $verifiedCleared, $closedDuplicate, $openFinding])
|
||||
->removeTableFilters()
|
||||
->filterTable('verification_state', FindingOutcomeSemantics::VERIFICATION_PENDING)
|
||||
->assertCanSeeTableRecords([$pendingVerification])
|
||||
->assertCanNotSeeTableRecords([$verifiedCleared, $closedDuplicate, $riskAccepted, $openFinding])
|
||||
->removeTableFilters()
|
||||
->filterTable('verification_state', FindingOutcomeSemantics::VERIFICATION_VERIFIED)
|
||||
->assertCanSeeTableRecords([$verifiedCleared])
|
||||
->assertCanNotSeeTableRecords([$pendingVerification, $closedDuplicate, $riskAccepted, $openFinding]);
|
||||
});
|
||||
|
||||
it('filters findings by high severity quick filter', function (): void {
|
||||
[, $tenant] = actingAsFindingsManagerForFilters();
|
||||
|
||||
|
||||
@ -13,23 +13,11 @@
|
||||
use App\Support\OperationRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
||||
function spec230ExpectedNotificationIcon(string $status): string
|
||||
{
|
||||
return (string) data_get(
|
||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
||||
'icon',
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function latestFindingNotificationFor(User $user): ?\Illuminate\Notifications\DatabaseNotification
|
||||
{
|
||||
return $user->notifications()
|
||||
@ -91,10 +79,6 @@ function runEvaluateAlertsForWorkspace(int $workspaceId): void
|
||||
|
||||
expect($firstNotification)->not->toBeNull()
|
||||
->and(data_get($firstNotification?->data, 'title'))->toBe('Finding assigned')
|
||||
->and(data_get($firstNotification?->data, 'status'))->toBe('info')
|
||||
->and(data_get($firstNotification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('info'))
|
||||
->and(data_get($firstNotification?->data, 'actions.0.label'))->toBe('Open finding')
|
||||
->and(data_get($firstNotification?->data, 'actions.0.target'))->toBe('finding_detail')
|
||||
->and(data_get($firstNotification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED);
|
||||
|
||||
$workflow->assign(
|
||||
@ -197,24 +181,6 @@ function runEvaluateAlertsForWorkspace(int $workspaceId): void
|
||||
expect(findingNotificationCountFor($assignee, AlertRule::EVENT_FINDINGS_DUE_SOON))->toBe(1)
|
||||
->and(findingNotificationCountFor($owner, AlertRule::EVENT_FINDINGS_OVERDUE))->toBe(1);
|
||||
|
||||
$dueSoonNotification = $assignee->notifications()
|
||||
->where('type', FindingEventNotification::class)
|
||||
->get()
|
||||
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_DUE_SOON);
|
||||
$overdueNotification = $owner->notifications()
|
||||
->where('type', FindingEventNotification::class)
|
||||
->get()
|
||||
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_OVERDUE);
|
||||
|
||||
expect($dueSoonNotification)->not->toBeNull()
|
||||
->and(data_get($dueSoonNotification?->data, 'status'))->toBe('warning')
|
||||
->and(data_get($dueSoonNotification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('warning'))
|
||||
->and(data_get($dueSoonNotification?->data, 'actions.0.label'))->toBe('Open finding');
|
||||
expect($overdueNotification)->not->toBeNull()
|
||||
->and(data_get($overdueNotification?->data, 'status'))->toBe('danger')
|
||||
->and(data_get($overdueNotification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('danger'))
|
||||
->and(data_get($overdueNotification?->data, 'actions.0.label'))->toBe('Open finding');
|
||||
|
||||
expect($assignee->notifications()
|
||||
->where('type', FindingEventNotification::class)
|
||||
->get()
|
||||
|
||||
@ -11,23 +11,11 @@
|
||||
use App\Services\Findings\FindingNotificationService;
|
||||
use App\Services\Findings\FindingWorkflowService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
|
||||
afterEach(function (): void {
|
||||
CarbonImmutable::setTestNow();
|
||||
});
|
||||
|
||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
||||
function spec230ExpectedNotificationIcon(string $status): string
|
||||
{
|
||||
return (string) data_get(
|
||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
||||
'icon',
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function dispatchedFindingNotificationsFor(User $user): \Illuminate\Support\Collection
|
||||
{
|
||||
return $user->notifications()
|
||||
@ -66,19 +54,6 @@ function dispatchedFindingNotificationsFor(User $user): \Illuminate\Support\Coll
|
||||
->all())
|
||||
->toContain(AlertRule::EVENT_FINDINGS_OVERDUE);
|
||||
|
||||
$assignedNotification = dispatchedFindingNotificationsFor($assignee)
|
||||
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_ASSIGNED);
|
||||
$overdueNotification = dispatchedFindingNotificationsFor($owner)
|
||||
->first(fn ($notification): bool => data_get($notification->data, 'finding_event.event_type') === AlertRule::EVENT_FINDINGS_OVERDUE);
|
||||
|
||||
expect($assignedNotification)->not->toBeNull()
|
||||
->and(data_get($assignedNotification?->data, 'status'))->toBe('info')
|
||||
->and(data_get($assignedNotification?->data, 'actions.0.target'))->toBe('finding_detail');
|
||||
expect($overdueNotification)->not->toBeNull()
|
||||
->and(data_get($overdueNotification?->data, 'status'))->toBe('danger')
|
||||
->and(data_get($overdueNotification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('danger'))
|
||||
->and(data_get($overdueNotification?->data, 'actions.0.target'))->toBe('finding_detail');
|
||||
|
||||
$fallbackFinding = Finding::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => Finding::STATUS_REOPENED,
|
||||
@ -220,7 +195,5 @@ function dispatchedFindingNotificationsFor(User $user): \Illuminate\Support\Coll
|
||||
|
||||
expect($result['direct_delivery_status'])->toBe('sent')
|
||||
->and(dispatchedFindingNotificationsFor($owner))->toHaveCount(1)
|
||||
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.finding_event.recipient_reason'))->toBe('current_owner')
|
||||
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.status'))->toBe('danger')
|
||||
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.actions.0.label'))->toBe('Open finding');
|
||||
->and(data_get(dispatchedFindingNotificationsFor($owner)->first(), 'data.finding_event.recipient_reason'))->toBe('current_owner');
|
||||
});
|
||||
|
||||
@ -12,11 +12,11 @@
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$finding = Finding::factory()->for($tenant)->permissionPosture()->create();
|
||||
|
||||
$finding = app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
|
||||
$finding = app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, 'permission_granted');
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->resolved_at)->not->toBeNull()
|
||||
->and($finding->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED);
|
||||
->and($finding->resolved_reason)->toBe('permission_granted');
|
||||
|
||||
$fresh = Finding::query()->find($finding->getKey());
|
||||
expect($fresh->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
@ -27,7 +27,7 @@
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$finding = Finding::factory()->for($tenant)->permissionPosture()->resolved()->create();
|
||||
|
||||
$finding = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user, Finding::REOPEN_REASON_MANUAL_REASSESSMENT);
|
||||
$finding = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user, 'The finding recurred after a later scan.');
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_REOPENED)
|
||||
->and($finding->reopened_at)->not->toBeNull()
|
||||
@ -39,11 +39,11 @@
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$finding = Finding::factory()->for($tenant)->permissionPosture()->create();
|
||||
|
||||
$finding->resolve(Finding::RESOLVE_REASON_PERMISSION_GRANTED);
|
||||
$finding->resolve('permission_granted');
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->resolved_at)->not->toBeNull()
|
||||
->and($finding->resolved_reason)->toBe(Finding::RESOLVE_REASON_PERMISSION_GRANTED);
|
||||
->and($finding->resolved_reason)->toBe('permission_granted');
|
||||
|
||||
$finding->reopen([
|
||||
'display_name' => 'Recovered Permission',
|
||||
@ -103,7 +103,7 @@
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_ACKNOWLEDGED);
|
||||
|
||||
$finding = app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, Finding::RESOLVE_REASON_REMEDIATED);
|
||||
$finding = app(FindingWorkflowService::class)->resolve($finding, $tenant, $user, 'permission_granted');
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->acknowledged_at)->not->toBeNull()
|
||||
@ -141,5 +141,5 @@
|
||||
|
||||
expect($finding->status)->toBe(Finding::STATUS_RESOLVED)
|
||||
->and($finding->resolved_at)->not->toBeNull()
|
||||
->and($finding->resolved_reason)->toBe(Finding::RESOLVE_REASON_REMEDIATED);
|
||||
->and($finding->resolved_reason)->toBe('permission_granted');
|
||||
});
|
||||
|
||||
@ -12,20 +12,8 @@
|
||||
use App\Services\Findings\FindingNotificationService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
||||
function spec230ExpectedNotificationIcon(string $status): string
|
||||
{
|
||||
return (string) data_get(
|
||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
||||
'icon',
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
it('stores a filament payload with one tenant finding deep link and recipient reason copy', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$assignee = User::factory()->create(['name' => 'Assigned Operator']);
|
||||
@ -48,58 +36,13 @@ function spec230ExpectedNotificationIcon(string $status): string
|
||||
expect($notification)->not->toBeNull()
|
||||
->and(data_get($notification?->data, 'format'))->toBe('filament')
|
||||
->and(data_get($notification?->data, 'title'))->toBe('Finding assigned')
|
||||
->and(data_get($notification?->data, 'status'))->toBe('info')
|
||||
->and(data_get($notification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('info'))
|
||||
->and(data_get($notification?->data, 'actions.0.label'))->toBe('Open finding')
|
||||
->and(data_get($notification?->data, 'actions.0.url'))
|
||||
->toBe(FindingResource::getUrl('view', ['record' => $finding], panel: 'tenant', tenant: $tenant))
|
||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('finding_detail')
|
||||
->and(data_get($notification?->data, 'supporting_lines'))->toBe(['You are the new assignee.'])
|
||||
->and(data_get($notification?->data, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED)
|
||||
->and(data_get($notification?->data, 'finding_event.recipient_reason'))->toBe('new_assignee');
|
||||
|
||||
$this->actingAs($assignee);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$this->get(data_get($notification?->data, 'actions.0.url'))->assertSuccessful();
|
||||
});
|
||||
|
||||
it('maps due soon and overdue finding notifications onto the shared status and icon treatment', function (
|
||||
string $eventType,
|
||||
string $recipient,
|
||||
string $expectedStatus,
|
||||
string $findingStatus,
|
||||
string $relativeDueAt,
|
||||
): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$assignee = User::factory()->create(['name' => 'Urgency Operator']);
|
||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => $findingStatus,
|
||||
'owner_user_id' => (int) $owner->getKey(),
|
||||
'assignee_user_id' => (int) $assignee->getKey(),
|
||||
'due_at' => now()->modify($relativeDueAt),
|
||||
]);
|
||||
|
||||
app(FindingNotificationService::class)->dispatch($finding, $eventType);
|
||||
|
||||
$notifiable = $recipient === 'owner' ? $owner : $assignee;
|
||||
$notification = $notifiable->notifications()
|
||||
->where('type', FindingEventNotification::class)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($notification)->not->toBeNull()
|
||||
->and(data_get($notification?->data, 'status'))->toBe($expectedStatus)
|
||||
->and(data_get($notification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon($expectedStatus))
|
||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('finding_detail');
|
||||
})->with([
|
||||
'due soon' => [AlertRule::EVENT_FINDINGS_DUE_SOON, 'assignee', 'warning', Finding::STATUS_TRIAGED, '+6 hours'],
|
||||
'overdue' => [AlertRule::EVENT_FINDINGS_OVERDUE, 'owner', 'danger', Finding::STATUS_IN_PROGRESS, '-2 hours'],
|
||||
]);
|
||||
|
||||
it('returns 404 when a finding notification link is opened after tenant access is removed', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$assignee = User::factory()->create(['name' => 'Removed Operator']);
|
||||
|
||||
@ -1,26 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PlatformUser;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Notifications\OperationRunQueued;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Auth\PlatformCapabilities;
|
||||
use App\Support\System\SystemOperationRunLinks;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
|
||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
||||
function spec230ExpectedNotificationIcon(string $status): string
|
||||
{
|
||||
return (string) data_get(
|
||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
||||
'icon',
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
it('emits a queued notification after successful dispatch (initiator only) with view link', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
@ -52,16 +37,8 @@ function spec230ExpectedNotificationIcon(string $status): string
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['body'] ?? null)->toBe('Queued for execution. Open the operation for progress and next steps.');
|
||||
expect(data_get($notification->data, 'status'))->toBe('info');
|
||||
expect(data_get($notification->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('info'));
|
||||
expect(data_get($notification->data, 'actions'))->toHaveCount(1);
|
||||
expect(data_get($notification->data, 'actions.0.label'))->toBe(OperationRunLinks::openLabel());
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::view($run, $tenant));
|
||||
expect(data_get($notification->data, 'actions.0.target'))->toBe('admin_operation_run');
|
||||
expect(array_values(data_get($notification->data, 'supporting_lines', [])))->toBe([]);
|
||||
|
||||
$this->get(data_get($notification->data, 'actions.0.url'))->assertSuccessful();
|
||||
});
|
||||
|
||||
it('does not emit queued notifications for runs without an initiator', function () {
|
||||
@ -111,36 +88,8 @@ function spec230ExpectedNotificationIcon(string $status): string
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['body'] ?? null)->toBe('Queued for execution. Open the operation for progress and next steps.');
|
||||
expect(data_get($notification->data, 'status'))->toBe('info');
|
||||
expect(data_get($notification->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('info'));
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::tenantlessView($run));
|
||||
expect(data_get($notification->data, 'actions.0.target'))->toBe('tenantless_operation_run');
|
||||
});
|
||||
|
||||
it('uses a tenantless view link for queued tenantless runs', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => null,
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'context' => [],
|
||||
]);
|
||||
|
||||
$user->notify(new OperationRunQueued($run));
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
expect($notification)->not->toBeNull()
|
||||
->and(data_get($notification?->data, 'actions.0.url'))->toBe(OperationRunLinks::tenantlessView($run))
|
||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('tenantless_operation_run');
|
||||
|
||||
$this->get(data_get($notification?->data, 'actions.0.url'))->assertSuccessful();
|
||||
});
|
||||
|
||||
it('emits a terminal notification when an operation run transitions to completed', function () {
|
||||
@ -182,15 +131,8 @@ function spec230ExpectedNotificationIcon(string $status): string
|
||||
expect($notification)->not->toBeNull();
|
||||
expect($notification->data['body'] ?? null)->toContain('Completed successfully.');
|
||||
expect($notification->data['body'] ?? null)->toContain('No action needed.');
|
||||
expect(data_get($notification->data, 'status'))->toBe('success');
|
||||
expect(data_get($notification->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('success'));
|
||||
expect(data_get($notification->data, 'actions'))->toHaveCount(1);
|
||||
expect(array_values(data_get($notification->data, 'supporting_lines', [])))->toContain('No action needed.', 'Total: 1');
|
||||
expect($notification->data['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::view($run, $tenant));
|
||||
expect(data_get($notification->data, 'actions.0.target'))->toBe('admin_operation_run');
|
||||
|
||||
$this->get(data_get($notification->data, 'actions.0.url'))->assertSuccessful();
|
||||
});
|
||||
|
||||
it('uses a tenantless view link for completed tenantless runs', function () {
|
||||
@ -226,42 +168,7 @@ function spec230ExpectedNotificationIcon(string $status): string
|
||||
->and($notification->data['body'] ?? null)->toContain('Execution prerequisite changed')
|
||||
->and($notification->data['body'] ?? null)->toContain('queued execution prerequisites are no longer satisfied')
|
||||
->and($notification->data['body'] ?? null)->not->toContain('execution_prerequisite_invalid')
|
||||
->and($notification->data['actions'][0]['url'] ?? null)->toBe(OperationRunLinks::tenantlessView($run))
|
||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('tenantless_operation_run');
|
||||
|
||||
$this->get(data_get($notification?->data, 'actions.0.url'))->assertSuccessful();
|
||||
});
|
||||
|
||||
it('uses the system operation route for completed notifications delivered to platform users', function (): void {
|
||||
$platformUser = PlatformUser::factory()->create([
|
||||
'capabilities' => [
|
||||
PlatformCapabilities::ACCESS_SYSTEM_PANEL,
|
||||
PlatformCapabilities::OPERATIONS_VIEW,
|
||||
],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => null,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
]);
|
||||
|
||||
$platformUser->notify(new OperationRunCompleted($run));
|
||||
|
||||
$notification = $platformUser->notifications()->latest('id')->first();
|
||||
|
||||
expect($notification)->not->toBeNull()
|
||||
->and(data_get($notification?->data, 'status'))->toBe('success')
|
||||
->and(data_get($notification?->data, 'icon'))->toBe(spec230ExpectedNotificationIcon('success'))
|
||||
->and(data_get($notification?->data, 'actions.0.label'))->toBe(OperationRunLinks::openLabel())
|
||||
->and(data_get($notification?->data, 'actions.0.url'))->toBe(SystemOperationRunLinks::view($run))
|
||||
->and(data_get($notification?->data, 'actions.0.target'))->toBe('system_operation_run');
|
||||
|
||||
$this->actingAs($platformUser, 'platform')
|
||||
->get(SystemOperationRunLinks::view($run))
|
||||
->assertSuccessful();
|
||||
->and($notification->data['actions'][0]['url'] ?? null)->toBe(OperationRunLinks::tenantlessView($run));
|
||||
});
|
||||
|
||||
it('renders partial backup-set update notifications with RBAC foundation summary counts', function () {
|
||||
|
||||
@ -1,229 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\AlertRule;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Notifications\Findings\FindingEventNotification;
|
||||
use App\Notifications\OperationRunCompleted;
|
||||
use App\Notifications\OperationRunQueued;
|
||||
use App\Services\Findings\FindingNotificationService;
|
||||
use App\Services\OperationRunService;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification as FilamentNotification;
|
||||
|
||||
if (! function_exists('spec230ExpectedNotificationIcon')) {
|
||||
function spec230ExpectedNotificationIcon(string $status): string
|
||||
{
|
||||
return (string) data_get(
|
||||
FilamentNotification::make()->status($status)->getDatabaseMessage(),
|
||||
'icon',
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (! function_exists('spec230AssertSharedNotificationPayload')) {
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array{
|
||||
* title: string,
|
||||
* status: string,
|
||||
* actionLabel: string,
|
||||
* actionTarget: string,
|
||||
* supportingLines: list<string>,
|
||||
* primaryBody: string
|
||||
* } $expected
|
||||
*/
|
||||
function spec230AssertSharedNotificationPayload(array $payload, array $expected): void
|
||||
{
|
||||
expect(data_get($payload, 'format'))->toBe('filament')
|
||||
->and((string) data_get($payload, 'title'))->toBe($expected['title'])
|
||||
->and((string) data_get($payload, 'body'))->toStartWith($expected['primaryBody'])
|
||||
->and(data_get($payload, 'status'))->toBe($expected['status'])
|
||||
->and(data_get($payload, 'icon'))->toBe(spec230ExpectedNotificationIcon($expected['status']))
|
||||
->and(data_get($payload, 'actions', []))->toHaveCount(1)
|
||||
->and(data_get($payload, 'actions.0.label'))->toBe($expected['actionLabel'])
|
||||
->and(data_get($payload, 'actions.0.target'))->toBe($expected['actionTarget'])
|
||||
->and(array_values(data_get($payload, 'supporting_lines', [])))->toBe($expected['supportingLines']);
|
||||
|
||||
foreach ($expected['supportingLines'] as $line) {
|
||||
expect((string) data_get($payload, 'body'))->toContain($line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('enforces the shared database notification contract across finding queued and completed consumers', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$assignee = \App\Models\User::factory()->create(['name' => 'Shared Contract Assignee']);
|
||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||
|
||||
$this->actingAs($owner);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => Finding::STATUS_TRIAGED,
|
||||
'severity' => 'high',
|
||||
'owner_user_id' => (int) $owner->getKey(),
|
||||
'assignee_user_id' => (int) $assignee->getKey(),
|
||||
]);
|
||||
|
||||
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
||||
|
||||
$queuedRun = app(OperationRunService::class)->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'policy.sync',
|
||||
inputs: ['scope' => 'all'],
|
||||
initiator: $owner,
|
||||
);
|
||||
|
||||
app(OperationRunService::class)->dispatchOrFail($queuedRun, function (): void {
|
||||
// no-op
|
||||
}, emitQueuedNotification: true);
|
||||
|
||||
$completedRun = OperationRun::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'user_id' => (int) $owner->getKey(),
|
||||
'initiator_name' => $owner->name,
|
||||
'type' => 'inventory_sync',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
]);
|
||||
|
||||
app(OperationRunService::class)->updateRun(
|
||||
$completedRun,
|
||||
status: 'completed',
|
||||
outcome: 'succeeded',
|
||||
summaryCounts: ['total' => 1],
|
||||
failures: [],
|
||||
);
|
||||
|
||||
$findingNotification = $assignee->notifications()
|
||||
->where('type', FindingEventNotification::class)
|
||||
->latest('id')
|
||||
->first();
|
||||
$queuedNotification = $owner->notifications()
|
||||
->where('type', OperationRunQueued::class)
|
||||
->latest('id')
|
||||
->first();
|
||||
$completedNotification = $owner->notifications()
|
||||
->where('type', OperationRunCompleted::class)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($findingNotification)->not->toBeNull();
|
||||
expect($queuedNotification)->not->toBeNull();
|
||||
expect($completedNotification)->not->toBeNull();
|
||||
|
||||
spec230AssertSharedNotificationPayload($findingNotification?->data ?? [], [
|
||||
'title' => 'Finding assigned',
|
||||
'primaryBody' => 'Finding #'.(int) $finding->getKey().' in '.$tenant->getFilamentName().' was assigned. High severity.',
|
||||
'status' => 'info',
|
||||
'actionLabel' => 'Open finding',
|
||||
'actionTarget' => 'finding_detail',
|
||||
'supportingLines' => ['You are the new assignee.'],
|
||||
]);
|
||||
|
||||
spec230AssertSharedNotificationPayload($queuedNotification?->data ?? [], [
|
||||
'title' => 'Policy sync queued',
|
||||
'primaryBody' => 'Queued for execution. Open the operation for progress and next steps.',
|
||||
'status' => 'info',
|
||||
'actionLabel' => 'Open operation',
|
||||
'actionTarget' => 'admin_operation_run',
|
||||
'supportingLines' => [],
|
||||
]);
|
||||
|
||||
spec230AssertSharedNotificationPayload($completedNotification?->data ?? [], [
|
||||
'title' => 'Inventory sync completed successfully',
|
||||
'primaryBody' => 'Completed successfully.',
|
||||
'status' => 'success',
|
||||
'actionLabel' => 'Open operation',
|
||||
'actionTarget' => 'admin_operation_run',
|
||||
'supportingLines' => ['No action needed.', 'Total: 1'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps exactly one primary action and preserves secondary metadata boundaries across in-scope consumers', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$assignee = \App\Models\User::factory()->create(['name' => 'Boundary Assignee']);
|
||||
createUserWithTenant(tenant: $tenant, user: $assignee, role: 'operator');
|
||||
|
||||
$this->actingAs($owner);
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'status' => Finding::STATUS_TRIAGED,
|
||||
'owner_user_id' => (int) $owner->getKey(),
|
||||
'assignee_user_id' => (int) $assignee->getKey(),
|
||||
]);
|
||||
|
||||
app(FindingNotificationService::class)->dispatch($finding, AlertRule::EVENT_FINDINGS_ASSIGNED);
|
||||
|
||||
$tenantlessQueuedRun = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => null,
|
||||
'user_id' => (int) $owner->getKey(),
|
||||
'initiator_name' => $owner->name,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
]);
|
||||
|
||||
$owner->notify(new OperationRunQueued($tenantlessQueuedRun));
|
||||
|
||||
$tenantlessCompletedRun = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => null,
|
||||
'user_id' => (int) $owner->getKey(),
|
||||
'initiator_name' => $owner->name,
|
||||
'type' => 'provider.connection.check',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
]);
|
||||
|
||||
app(OperationRunService::class)->updateRun(
|
||||
$tenantlessCompletedRun,
|
||||
status: 'completed',
|
||||
outcome: 'blocked',
|
||||
failures: [[
|
||||
'code' => 'operation.blocked',
|
||||
'reason_code' => 'execution_prerequisite_invalid',
|
||||
'message' => 'Operation blocked because the queued execution prerequisites are no longer satisfied.',
|
||||
]],
|
||||
);
|
||||
|
||||
$findingPayload = data_get(
|
||||
$assignee->notifications()->where('type', FindingEventNotification::class)->latest('id')->first(),
|
||||
'data',
|
||||
[],
|
||||
);
|
||||
$queuedPayload = data_get(
|
||||
$owner->notifications()->where('type', OperationRunQueued::class)->latest('id')->first(),
|
||||
'data',
|
||||
[],
|
||||
);
|
||||
$completedPayload = data_get(
|
||||
$owner->notifications()->where('type', OperationRunCompleted::class)->latest('id')->first(),
|
||||
'data',
|
||||
[],
|
||||
);
|
||||
|
||||
expect(data_get($findingPayload, 'actions', []))->toHaveCount(1)
|
||||
->and(data_get($queuedPayload, 'actions', []))->toHaveCount(1)
|
||||
->and(data_get($completedPayload, 'actions', []))->toHaveCount(1)
|
||||
->and(data_get($findingPayload, 'finding_event.event_type'))->toBe(AlertRule::EVENT_FINDINGS_ASSIGNED)
|
||||
->and(data_get($findingPayload, 'reason_translation'))->toBeNull()
|
||||
->and(data_get($queuedPayload, 'finding_event'))->toBeNull()
|
||||
->and(data_get($queuedPayload, 'reason_translation'))->toBeNull()
|
||||
->and(data_get($completedPayload, 'finding_event'))->toBeNull()
|
||||
->and(data_get($completedPayload, 'reason_translation.operator_label'))->toBe('Execution prerequisite changed')
|
||||
->and(data_get($completedPayload, 'actions.0.target'))->toBe('tenantless_operation_run')
|
||||
->and(array_values(data_get($queuedPayload, 'supporting_lines', [])))->toBe([])
|
||||
->and(array_values(data_get($completedPayload, 'supporting_lines', [])))->toContain('Execution prerequisite changed');
|
||||
});
|
||||
@ -52,67 +52,3 @@
|
||||
|
||||
expect($violations)->toBe([]);
|
||||
})->group('ops-ux');
|
||||
|
||||
it('keeps in-scope database notifications routed through the shared presenter seam', function (): void {
|
||||
$root = SourceFileScanner::projectRoot();
|
||||
$files = [
|
||||
$root.'/app/Notifications/Findings/FindingEventNotification.php',
|
||||
$root.'/app/Notifications/OperationRunQueued.php',
|
||||
$root.'/app/Notifications/OperationRunCompleted.php',
|
||||
];
|
||||
$needles = [
|
||||
'FilamentNotification::make(',
|
||||
'->getDatabaseMessage(',
|
||||
];
|
||||
$violations = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$source = SourceFileScanner::read($file);
|
||||
|
||||
foreach ($needles as $needle) {
|
||||
if (! str_contains($source, $needle)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$offset = 0;
|
||||
|
||||
while (($position = strpos($source, $needle, $offset)) !== false) {
|
||||
$line = substr_count(substr($source, 0, $position), "\n") + 1;
|
||||
|
||||
$violations[] = [
|
||||
'file' => SourceFileScanner::relativePath($file),
|
||||
'line' => $line,
|
||||
'snippet' => SourceFileScanner::snippet($source, $line),
|
||||
];
|
||||
|
||||
$offset = $position + strlen($needle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($violations !== []) {
|
||||
$messages = array_map(static function (array $violation): string {
|
||||
return sprintf(
|
||||
"%s:%d\n%s",
|
||||
$violation['file'],
|
||||
$violation['line'],
|
||||
$violation['snippet'],
|
||||
);
|
||||
}, $violations);
|
||||
|
||||
$this->fail(
|
||||
"Local database-notification payload composition found in in-scope consumers:\n\n".implode("\n\n", $messages)
|
||||
);
|
||||
}
|
||||
|
||||
expect($violations)->toBe([]);
|
||||
})->group('ops-ux');
|
||||
|
||||
it('keeps alert email delivery outside the shared database notification contract boundary', function (): void {
|
||||
$source = SourceFileScanner::read(
|
||||
SourceFileScanner::projectRoot().'/app/Notifications/Alerts/EmailAlertNotification.php'
|
||||
);
|
||||
|
||||
expect($source)->not->toContain('OperationUxPresenter')
|
||||
->and($source)->not->toContain('FilamentNotification');
|
||||
})->group('ops-ux');
|
||||
|
||||
@ -35,7 +35,7 @@
|
||||
'severity' => Finding::SEVERITY_HIGH,
|
||||
'status' => Finding::STATUS_RESOLVED,
|
||||
'resolved_at' => now()->subDay(),
|
||||
'resolved_reason' => Finding::RESOLVE_REASON_REMEDIATED,
|
||||
'resolved_reason' => 'fixed',
|
||||
'closed_at' => now()->subHours(2),
|
||||
'closed_reason' => 'legacy-close',
|
||||
'closed_by_user_id' => $user->getKey(),
|
||||
@ -43,7 +43,7 @@
|
||||
'due_at' => now()->subDays(10),
|
||||
]);
|
||||
|
||||
$reopened = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user, Finding::REOPEN_REASON_MANUAL_REASSESSMENT);
|
||||
$reopened = app(FindingWorkflowService::class)->reopen($finding, $tenant, $user, 'The issue recurred after verification.');
|
||||
|
||||
expect($reopened->status)->toBe(Finding::STATUS_REOPENED)
|
||||
->and($reopened->reopened_at?->toIso8601String())->toBe('2026-02-24T10:00:00+00:00')
|
||||
|
||||
@ -5,7 +5,7 @@ # Spec Candidates
|
||||
>
|
||||
> **Flow**: Inbox → Qualified → Planned → Spec created → moved to `Promoted to Spec`
|
||||
|
||||
**Last reviewed**: 2026-04-22 (promoted `Findings Notifications & Escalation v1` to Spec 224, promoted `Findings Notification Presentation Convergence` to Spec 230, added three architecture contract-enforcement candidates from the 2026-04-22 drift audit, added the repository cleanup strand from the strict read-only legacy audit, reframed the compliance-control foundation candidate into a framework-neutral canonical control catalog foundation, and aligned the control-library candidates to the S1/S2/S3 layering language)
|
||||
**Last reviewed**: 2026-04-22 (promoted `Findings Notifications & Escalation v1` to Spec 224, added `Findings Notification Presentation Convergence`, added three architecture contract-enforcement candidates from the 2026-04-22 drift audit, added the repository cleanup strand from the strict read-only legacy audit, reframed the compliance-control foundation candidate into a framework-neutral canonical control catalog foundation, and aligned the control-library candidates to the S1/S2/S3 layering language)
|
||||
|
||||
---
|
||||
|
||||
@ -47,7 +47,6 @@ ## Promoted to Spec
|
||||
- Findings Operator Inbox v1 → Spec 221 (`findings-operator-inbox`)
|
||||
- Findings Intake & Team Queue v1 -> Spec 222 (`findings-intake-team-queue`)
|
||||
- Findings Notifications & Escalation v1 → Spec 224 (`findings-notifications-escalation`)
|
||||
- Findings Notification Presentation Convergence → Spec 230 (`findings-notification-convergence`)
|
||||
- Provider-Backed Action Preflight and Dispatch Gate Unification → Spec 216 (`provider-dispatch-gate`)
|
||||
- Record Page Header Discipline & Contextual Navigation → Spec 192 (`record-header-discipline`)
|
||||
- Monitoring Surface Action Hierarchy & Workbench Semantics → Spec 193 (`monitoring-action-hierarchy`)
|
||||
@ -444,6 +443,25 @@ ### Assignment Hygiene & Stale Work Detection
|
||||
- **Strategic sequencing**: Shortly after ownership semantics, ideally alongside or immediately after notifications.
|
||||
- **Priority**: high
|
||||
|
||||
### Findings Notification Presentation Convergence
|
||||
- **Type**: workflow hardening / cross-cutting presentation
|
||||
- **Source**: Spec 224 follow-up review 2026-04-22; shared notification-pattern drift analysis
|
||||
- **Problem**: Spec 224 closed the functional delivery gap for findings notifications, but the current in-app findings path appears to compose its presentation locally instead of fully extending the existing shared operator-facing notification presentation path. The result is not a second transport stack, but a second presentation path for the same interaction type.
|
||||
- **Why it matters**: Notifications are part of TenantPilot's operator-facing decision system, not just incidental UI. If findings notifications keep a local presentation language while operation or run notifications follow a different shared path, the product accumulates UX drift, duplicated payload semantics, and a higher risk that future alerts, assignment reminders, risk-acceptance renewals, and later `My Work` entry surfaces will grow another parallel path instead of converging.
|
||||
- **Proposed direction**:
|
||||
- inventory the current in-app / database-notification presentation paths and explicitly separate delivery/routing, stored payload, presentation contract, and deep-link semantics
|
||||
- define one repo-internal shared presentation contract for operator-facing database notifications that covers at least title, body, tone or status, icon, primary action, deep link, and optional supporting context
|
||||
- align findings in-app notifications to that shared path without changing the delivery semantics, recipient resolution, dedupe or fingerprint logic, or optional external-copy behavior introduced by Spec 224
|
||||
- add contract-level regression tests and guardrail notes so future notification types extend the shared presentation path instead of building local Filament payloads directly
|
||||
- **Explicit non-goals**: Not a redesign of the alerting or routing system. Not a remodelling of external notification targets or alert rules. Not a full `My Work` or inbox implementation. Not an immediate full-sweep unification of every historical notification class in the repo. Not a rewrite of escalation rules or notification-content priority.
|
||||
- **Dependencies**: Spec 224 (`findings-notifications-escalation`), existing operator-facing in-app notification paths (especially operation/run notifications), repo-wide cross-cutting presentation guardrails, and any current shared notification UX helpers or presenters.
|
||||
- **Boundary with Spec 224**: Spec 224 owns who gets notified, when, by which event type, with what fingerprint and optional external copies. This candidate keeps that delivery path intact and converges only the in-app presentation path.
|
||||
- **Boundary with Operator Presentation & Lifecycle Action Hardening**: Presentation hardening is the broader shared-convention candidate across many lifecycle-driven surfaces. This candidate is narrower: it uses in-app notifications as the first bounded convergence target for a shared operator-facing notification presentation contract.
|
||||
- **Boundary with My Work — Actionable Alerts**: `My Work — Actionable Alerts` decides which alert-like items deserve admission into a personal work surface. This candidate decides how operator-facing in-app notifications should present themselves consistently before any future `My Work` routing consumes them.
|
||||
- **Roadmap fit**: Findings Workflow v2 hardening plus cross-cutting operator-notification consistency.
|
||||
- **Strategic sequencing**: Best tackled soon after Spec 224 while the findings notification path is still fresh and before more notification-bearing domains adopt the same local composition pattern.
|
||||
- **Priority**: high
|
||||
|
||||
### Finding Outcome Taxonomy & Verification Semantics
|
||||
- **Type**: workflow semantics / reporting hardening
|
||||
- **Source**: findings execution layer candidate pack 2026-04-17; status/outcome reporting gap analysis
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
# Specification Quality Checklist: Findings Notification Presentation Convergence
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-04-22
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
- Repo-mandatory cross-cutting and validation sections reference current shared paths and test entry points, but the feature scope, user scenarios, requirements, and success criteria stay implementation-agnostic.
|
||||
- No open clarification markers remain. The candidate is ready for `/speckit.plan`.
|
||||
@ -1,235 +0,0 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Operator Database Notification Presentation Contract
|
||||
version: 1.0.0
|
||||
summary: Logical internal contract for Spec 230 shared database notification presentation and preserved destination routes.
|
||||
description: |
|
||||
This contract documents the shared primary payload structure that Spec 230 must enforce
|
||||
across the current operator-facing database notification consumers. It is intentionally
|
||||
logical rather than a public HTTP API because the feature reuses existing Filament database
|
||||
notifications, existing destination routes, and existing helper seams instead of introducing
|
||||
a new controller namespace.
|
||||
servers:
|
||||
- url: https://logical.internal
|
||||
description: Non-routable placeholder used to describe internal repository contracts.
|
||||
paths:
|
||||
/internal/operator-database-notifications/presentation:
|
||||
post:
|
||||
summary: Build one shared operator-facing database notification payload for an in-scope consumer.
|
||||
description: |
|
||||
Logical internal contract implemented by the shared presentation seam on top of the
|
||||
existing presenter and helper paths. It standardizes title, body, status with the
|
||||
corresponding existing Filament icon treatment, one primary action, and optional
|
||||
supporting context while preserving consumer-specific metadata.
|
||||
operationId: presentOperatorDatabaseNotification
|
||||
x-not-public-http: true
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/vnd.tenantpilot.operator-database-notification-input+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatorDatabaseNotificationInput'
|
||||
responses:
|
||||
'200':
|
||||
description: Shared primary notification structure returned for storage in the existing notifications table.
|
||||
content:
|
||||
application/vnd.tenantpilot.operator-database-notification-message+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OperatorDatabaseNotificationMessage'
|
||||
/admin/notifications:
|
||||
get:
|
||||
summary: Existing Filament drawer renders converged finding and operation notifications.
|
||||
operationId: viewOperatorDatabaseNotifications
|
||||
responses:
|
||||
'200':
|
||||
description: Existing shell renders the shared card grammar for the current in-scope consumers.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
application/vnd.tenantpilot.operator-notification-drawer+json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/NotificationDrawerSurface'
|
||||
/admin/t/{tenant}/findings/{finding}:
|
||||
get:
|
||||
summary: Finding notification action opens the existing tenant finding detail route.
|
||||
operationId: openFindingNotificationTarget
|
||||
parameters:
|
||||
- name: tenant
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
- name: finding
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Existing finding detail route renders for an entitled tenant operator.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'403':
|
||||
description: Recipient remains in scope but lacks current capability to inspect the finding.
|
||||
'404':
|
||||
description: Recipient no longer has tenant or record visibility.
|
||||
/admin/operations/{run}:
|
||||
get:
|
||||
summary: Admin or tenantless operation notification action opens the existing run detail route.
|
||||
operationId: openAdminOperationNotificationTarget
|
||||
parameters:
|
||||
- name: run
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Existing admin-plane or tenantless operation detail route renders for an entitled operator.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'403':
|
||||
description: Recipient is in scope but lacks current operation-view capability.
|
||||
'404':
|
||||
description: Recipient no longer has access to the route scope or the run.
|
||||
/system/ops/runs/{run}:
|
||||
get:
|
||||
summary: Platform-user operation notification action opens the existing system-panel run detail route.
|
||||
operationId: openSystemOperationNotificationTarget
|
||||
parameters:
|
||||
- name: run
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Existing system-panel run detail renders for an entitled platform user.
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
'403':
|
||||
description: Platform user lacks the required operations capability.
|
||||
'404':
|
||||
description: Platform user cannot access the run in the current plane.
|
||||
components:
|
||||
schemas:
|
||||
NotificationConsumer:
|
||||
type: string
|
||||
enum:
|
||||
- finding_event
|
||||
- operation_run_queued
|
||||
- operation_run_completed
|
||||
NotificationStatus:
|
||||
type: string
|
||||
description: Shared status emphasis that also drives the existing Filament icon treatment for the card.
|
||||
enum:
|
||||
- info
|
||||
- success
|
||||
- warning
|
||||
- danger
|
||||
NotificationTarget:
|
||||
type: string
|
||||
enum:
|
||||
- finding_detail
|
||||
- admin_operation_run
|
||||
- tenantless_operation_run
|
||||
- system_operation_run
|
||||
NotificationPrimaryAction:
|
||||
type: object
|
||||
required:
|
||||
- label
|
||||
- url
|
||||
- target
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
target:
|
||||
$ref: '#/components/schemas/NotificationTarget'
|
||||
OperatorDatabaseNotificationInput:
|
||||
type: object
|
||||
required:
|
||||
- consumer
|
||||
- title
|
||||
- body
|
||||
- status
|
||||
- primaryAction
|
||||
properties:
|
||||
consumer:
|
||||
$ref: '#/components/schemas/NotificationConsumer'
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
status:
|
||||
$ref: '#/components/schemas/NotificationStatus'
|
||||
primaryAction:
|
||||
$ref: '#/components/schemas/NotificationPrimaryAction'
|
||||
supportingLines:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: |
|
||||
Shared input model for the bounded presentation seam. Metadata remains consumer-specific,
|
||||
but the primary title, body, status with the existing Filament icon treatment, and action structure must stay consistent.
|
||||
OperatorDatabaseNotificationMessage:
|
||||
type: object
|
||||
required:
|
||||
- title
|
||||
- body
|
||||
- status
|
||||
- actions
|
||||
properties:
|
||||
title:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
status:
|
||||
$ref: '#/components/schemas/NotificationStatus'
|
||||
actions:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 1
|
||||
items:
|
||||
$ref: '#/components/schemas/NotificationPrimaryAction'
|
||||
supportingLines:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: |
|
||||
Shared persisted message shape stored in the existing notifications table and rendered by
|
||||
the current Filament database-notification drawer.
|
||||
NotificationDrawerSurface:
|
||||
type: object
|
||||
required:
|
||||
- consumers
|
||||
- structureGuarantees
|
||||
properties:
|
||||
consumers:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/NotificationConsumer'
|
||||
structureGuarantees:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example:
|
||||
- one primary title
|
||||
- one primary body summarizing the change
|
||||
- one status emphasis with the existing Filament icon treatment
|
||||
- exactly one primary action
|
||||
- optional supporting lines remain secondary
|
||||
@ -1,190 +0,0 @@
|
||||
# Data Model: Findings Notification Presentation Convergence
|
||||
|
||||
## Overview
|
||||
|
||||
This feature introduces no new persisted business entity. Existing finding truth, operation-run truth, deep-link helpers, and database-notification rows remain canonical. The new work is a bounded derived presentation layer over those existing records.
|
||||
|
||||
## Existing Persistent Entities
|
||||
|
||||
### Database Notification (`notifications` table)
|
||||
|
||||
**Purpose**: Existing persisted delivery artifact for operator-facing in-app notifications.
|
||||
|
||||
**Key fields used by this feature**:
|
||||
|
||||
- `id`
|
||||
- `type`
|
||||
- `notifiable_type`
|
||||
- `notifiable_id`
|
||||
- `data`
|
||||
- `read_at`
|
||||
- `created_at`
|
||||
|
||||
**Rules relevant to convergence**:
|
||||
|
||||
- The feature changes only the derived primary payload shape stored in `data`.
|
||||
- Existing namespaced metadata such as `finding_event`, `reason_translation`, and `diagnostic_reason_code` remains secondary and consumer-specific.
|
||||
- No new table or projection is added.
|
||||
|
||||
### Finding
|
||||
|
||||
**Purpose**: Canonical tenant-scoped truth for finding identity, severity, lifecycle, and notification-event context.
|
||||
|
||||
**Key fields used by this feature**:
|
||||
|
||||
- `id`
|
||||
- `tenant_id`
|
||||
- `workspace_id`
|
||||
- `severity`
|
||||
- `status`
|
||||
- `owner_user_id`
|
||||
- `assignee_user_id`
|
||||
- `due_at`
|
||||
|
||||
**Rules relevant to convergence**:
|
||||
|
||||
- The feature does not change how finding events are generated.
|
||||
- Finding links continue to resolve against the existing tenant-panel detail route.
|
||||
- Finding-event metadata remains available for downstream consumers and tests.
|
||||
|
||||
### OperationRun
|
||||
|
||||
**Purpose**: Canonical truth for operation lifecycle, scope, outcome, and supporting notification context.
|
||||
|
||||
**Key fields used by this feature**:
|
||||
|
||||
- `id`
|
||||
- `type`
|
||||
- `status`
|
||||
- `outcome`
|
||||
- `tenant_id`
|
||||
- `context`
|
||||
- `summary_counts`
|
||||
- `failure_summary`
|
||||
|
||||
**Rules relevant to convergence**:
|
||||
|
||||
- The feature does not change queued or terminal notification emit rules.
|
||||
- Existing admin-plane, tenantless, and system-plane link resolution remains authoritative.
|
||||
- Completed-run guidance and reason translation remain derived from current run truth.
|
||||
|
||||
### Notifiable Context
|
||||
|
||||
**Purpose**: Determines which route family and supporting context a notification may expose.
|
||||
|
||||
**Relevant notifiable cases**:
|
||||
|
||||
- tenant-scoped operator receiving a finding notification
|
||||
- workspace operator receiving an admin-plane operation notification
|
||||
- platform user receiving a system-plane operation notification
|
||||
|
||||
**Rules relevant to convergence**:
|
||||
|
||||
- Shared presentation must not erase plane-specific destination behavior.
|
||||
- The shared contract can adapt the action URL by notifiable context, but it cannot widen visibility or flatten authorization semantics.
|
||||
|
||||
## Derived Models
|
||||
|
||||
### OperatorDatabaseNotificationPresentation
|
||||
|
||||
**Purpose**: Shared derived contract for the primary structure rendered in the existing Filament notification drawer.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `title`
|
||||
- `body`
|
||||
- `status`
|
||||
- `primaryAction.label`
|
||||
- `primaryAction.url`
|
||||
- `primaryAction.target`
|
||||
- `supportingLines[]`
|
||||
- `metadata`
|
||||
|
||||
**Validation rules**:
|
||||
|
||||
- Every in-scope consumer provides exactly one primary action.
|
||||
- `status` remains the single source for the existing Filament icon treatment; the feature does not introduce a second icon taxonomy.
|
||||
- `supportingLines` is optional and never replaces `body` or the primary action.
|
||||
- `metadata` may carry consumer-specific namespaced fields, but the shared primary structure remains stable.
|
||||
|
||||
### NotificationPrimaryAction
|
||||
|
||||
**Purpose**: Canonical one-action model for secondary context notifications.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `label`
|
||||
- `url`
|
||||
- `target`
|
||||
|
||||
**Allowed targets**:
|
||||
|
||||
- `finding_detail`
|
||||
- `admin_operation_run`
|
||||
- `tenantless_operation_run`
|
||||
- `system_operation_run`
|
||||
|
||||
**Rules**:
|
||||
|
||||
- There is exactly one primary action per in-scope card.
|
||||
- The action source remains the existing canonical link helper for that domain and plane.
|
||||
|
||||
### FindingNotificationPresentationInput
|
||||
|
||||
**Purpose**: Consumer-specific derived input used by the shared contract for `FindingEventNotification`.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `findingId`
|
||||
- `tenantId`
|
||||
- `eventType`
|
||||
- `title`
|
||||
- `body`
|
||||
- `recipientReason`
|
||||
- `tenantName`
|
||||
- `severity`
|
||||
- `fingerprintKey`
|
||||
- `dueCycleKey`
|
||||
|
||||
**Rules**:
|
||||
|
||||
- Primary wording remains finding-first and uses `Open finding` as the action label.
|
||||
- `recipientReason` stays supporting context, not the headline.
|
||||
|
||||
### OperationRunNotificationPresentationInput
|
||||
|
||||
**Purpose**: Consumer-specific derived input used by the shared contract for queued and terminal run notifications.
|
||||
|
||||
**Fields**:
|
||||
|
||||
- `runId`
|
||||
- `operationType`
|
||||
- `status`
|
||||
- `outcome`
|
||||
- `targetPlane`
|
||||
- `openUrl`
|
||||
- `openLabel`
|
||||
- `guidanceLines[]`
|
||||
- `summaryLine`
|
||||
- `reasonTranslation`
|
||||
|
||||
**Rules**:
|
||||
|
||||
- Queued notifications keep their existing queued vocabulary but adopt the shared card structure.
|
||||
- Completed notifications preserve terminal explanation, summary, and diagnostic fields as supporting context.
|
||||
- Platform users resolve to the system-panel run detail route; non-platform users keep current admin or tenantless behavior.
|
||||
|
||||
## Consumer Matrix
|
||||
|
||||
| Consumer | Source truth | Primary action target | Required shared fields | Preserved secondary metadata |
|
||||
|----------|--------------|-----------------------|------------------------|------------------------------|
|
||||
| `FindingEventNotification` | `Finding` plus existing event envelope | tenant finding detail | title, body, status with existing icon treatment, `Open finding`, tenant-safe URL | `finding_event` with recipient reason, fingerprint, tenant name, severity |
|
||||
| `OperationRunQueued` | `OperationRun` queued state | admin or tenantless operation run view | title, body, status with existing icon treatment, open-run label, resolved URL | minimal context derived from current run state only |
|
||||
| `OperationRunCompleted` | `OperationRun` terminal state | admin, tenantless, or system operation run view | title, body, status with existing icon treatment, open-run label, resolved URL | `reason_translation`, `diagnostic_reason_code`, summary lines, failure guidance |
|
||||
|
||||
## Persistence Boundaries
|
||||
|
||||
- No new table, enum-backed persistence, or presentation-only cache is introduced.
|
||||
- The shared notification contract remains derived from existing finding and operation-run truth.
|
||||
- Existing `notifications.data` remains the only persisted artifact for in-app delivery.
|
||||
- Existing event semantics from Spec 224 and current operation notification behavior remain unchanged.
|
||||
@ -1,255 +0,0 @@
|
||||
# Implementation Plan: Findings Notification Presentation Convergence
|
||||
|
||||
**Branch**: `230-findings-notification-convergence` | **Date**: 2026-04-22 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/230-findings-notification-convergence/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/230-findings-notification-convergence/spec.md`
|
||||
|
||||
**Note**: This plan keeps the work inside the existing Filament database-notification drawer, the current `notifications` table payload shape, the existing operation-link helpers, and the current `OperationUxPresenter` seam. The intended implementation converges the three in-scope operator-facing database notification consumers on one bounded shared presentation contract. It does not add a table, a notification center, a new panel, a preference system, a new asset family, or new notification-routing semantics.
|
||||
|
||||
## Summary
|
||||
|
||||
Extend the existing `OperationUxPresenter::terminalDatabaseNotification()` seam into one bounded operator-facing database-notification presentation contract, then align `FindingEventNotification`, `OperationRunQueued`, and `OperationRunCompleted` to that shared structure while preserving their existing deep-link sources, delivery semantics, and domain-specific metadata. Keep the existing Filament database-notification surface, keep the current admin-plane and system-plane run destinations, and add focused regression coverage that proves one primary structure, one status emphasis with the existing Filament icon treatment, one primary action, unchanged authorization behavior, and the preserved FR-015 out-of-scope boundary across the in-scope consumers.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15, Laravel 12, Filament v5, Livewire v4, Blade
|
||||
**Primary Dependencies**: Laravel database notifications, Filament notifications and actions, `App\Support\OpsUx\OperationUxPresenter`, `App\Notifications\Findings\FindingEventNotification`, `App\Notifications\OperationRunQueued`, `App\Notifications\OperationRunCompleted`, `FindingResource`, `OperationRunLinks`, `SystemOperationRunLinks`, `ReasonPresenter`
|
||||
**Storage**: PostgreSQL via the existing `notifications` table and existing `findings` plus `operation_runs` truth; no schema changes planned
|
||||
**Testing**: Pest v4 feature tests with notification-payload assertions and route-authorization coverage
|
||||
**Validation Lanes**: fast-feedback, confidence
|
||||
**Target Platform**: Dockerized Laravel web application via Sail locally and Linux containers in deployment
|
||||
**Project Type**: Laravel monolith inside the `wt-plattform` monorepo
|
||||
**Performance Goals**: Keep notification payload composition request-local, avoid extra N+1 lookups for titles or links, and preserve current drawer rendering without new polling or asset work
|
||||
**Constraints**: No schema migration, no new notification center, no new preference model, no new `OperationRun` type or emit point, no change to current queued or terminal run-notification semantics, no global-search changes, and no destructive action paths
|
||||
**Scale/Scope**: Three existing notification consumers, one existing shared presenter seam, three existing deep-link helpers, and six focused feature or guard suites
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces
|
||||
- **Native vs custom classification summary**: native Filament database-notification drawer and existing detail destinations only
|
||||
- **Shared-family relevance**: operator-facing database notifications, action links
|
||||
- **State layers in scope**: shell, detail
|
||||
- **Handling modes by drift class or surface**: review-mandatory
|
||||
- **Repository-signal treatment**: review-mandatory
|
||||
- **Special surface test profiles**: global-context-shell, standard-native-filament
|
||||
- **Required tests or manual smoke**: functional-core, state-contract
|
||||
- **Exception path and spread control**: one named exception only; platform-user operation notifications keep their existing system-panel destination while sharing the same card structure and primary action grammar
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes
|
||||
- **Systems touched**: `FindingEventNotification`, `OperationRunQueued`, `OperationRunCompleted`, `OperationUxPresenter`, `FindingResource`, `OperationRunLinks`, `SystemOperationRunLinks`, the existing Filament database-notification surface
|
||||
- **Shared abstractions reused**: `OperationUxPresenter::terminalDatabaseNotification()`, `FindingResource::getUrl(...)`, `OperationRunLinks`, `SystemOperationRunLinks`
|
||||
- **New abstraction introduced? why?**: one bounded shared operator-facing database-notification presentation contract, needed because three real consumers already exist and two still bypass the only shared presenter anchor
|
||||
- **Why the existing abstraction was sufficient or insufficient**: the current `OperationUxPresenter` seam is sufficient as the starting anchor because it already owns the terminal run-notification grammar. It is insufficient as-is because findings and queued run notifications still build local Filament payloads, which leaves the shared interaction family without one explicit contract.
|
||||
- **Bounded deviation / spread control**: domain-specific metadata stays namespaced and secondary, and platform-user operation links keep their existing system-panel route. No other divergence from the shared primary structure is allowed in the in-scope consumers.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Passed before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Pre-Research | Post-Design | Notes |
|
||||
|-----------|--------------|-------------|-------|
|
||||
| Shared pattern first (XCUT-001) | PASS | PASS | The plan extends the existing `OperationUxPresenter` seam rather than creating a second local notification grammar or a framework |
|
||||
| Proportionality / no premature abstraction | PASS | PASS | One bounded shared contract is justified by three real consumers; no registry, factory, or universal notification platform is introduced |
|
||||
| RBAC-UX / tenant isolation | PASS | PASS | Finding links remain tenant-scoped and operation links keep current admin-plane or system-plane resolution; `404` versus `403` behavior remains unchanged |
|
||||
| Ops-UX scope discipline | PASS | PASS | The feature does not add new `OperationRun` notifications or emit points; it only converges payload composition for the already-existing queued and terminal notification consumers |
|
||||
| Filament-native UI / action-surface contract | PASS | PASS | Existing Filament database notifications remain the only shell, each card keeps exactly one primary action, and no destructive action is introduced |
|
||||
| Livewire v4.0+ / Filament v5 compliance | PASS | PASS | The feature stays within the current Filament v5 and Livewire v4 notification primitives |
|
||||
| Provider registration / global search / assets | PASS | PASS | Provider registration remains in `apps/platform/bootstrap/providers.php`; no globally searchable resource changes; no new assets, and the existing deploy step `cd apps/platform && php artisan filament:assets` remains sufficient |
|
||||
| Test governance (TEST-GOV-001) | PASS | PASS | Focused feature and guard coverage prove payload convergence and route safety without browser or heavy-governance expansion |
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: `Feature` for notification presentation convergence, deep-link safety, and guardrail enforcement across the in-scope consumers
|
||||
- **Affected validation lanes**: `fast-feedback`, `confidence`
|
||||
- **Why this lane mix is the narrowest sufficient proof**: The risk is shared operator-facing payload drift and route-safety regression, not browser rendering or background orchestration. Focused feature suites plus one guard test prove the contract with minimal cost.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: Moderate. Tests need a tenant user, a platform user, a tenant, findings with existing event payloads, operation runs in queued and terminal states, and existing authorization helpers for tenant and system planes.
|
||||
- **Expensive defaults or shared helper growth introduced?**: no; any shared notification assertion helper should stay local to notification-contract tests and reuse existing factories and route helpers
|
||||
- **Heavy-family additions, promotions, or visibility changes**: none
|
||||
- **Surface-class relief / special coverage rule**: `global-context-shell` for drawer payloads that open tenant and system detail pages, with `standard-native-filament` relief because the shell itself remains native Filament
|
||||
- **Closing validation and reviewer handoff**: Reviewers should rely on the commands above and verify that the in-scope consumers now share the same primary title, body, status with the corresponding existing Filament icon treatment, and action structure; that `reason_translation` and `finding_event` metadata stay secondary; that the platform-user run destination remains `/system/ops/runs/{run}`; that alert delivery, escalation, and `My Work` admission behavior remain untouched; and that no in-scope class still builds its primary payload through a fully local `FilamentNotification::make()->getDatabaseMessage()` path.
|
||||
- **Budget / baseline / trend follow-up**: none
|
||||
- **Review-stop questions**: Did the implementation introduce a framework beyond one bounded shared contract? Did any in-scope consumer change its delivery semantics or action target instead of only its presentation path? Did the work widen payload disclosure or flatten plane-specific route rules? Did alert delivery, escalation, or `My Work` admission behavior change? Did a new asset, polling, or custom notification shell appear?
|
||||
- **Escalation path**: document-in-feature unless convergence pressure expands beyond the current three consumers or requires new persistence, in which case split or follow up with a dedicated spec
|
||||
- **Active feature PR close-out entry**: Guardrail
|
||||
- **Why no dedicated follow-up spec is needed**: This feature closes one current-release drift seam across three real consumers without introducing broader notification-platform scope.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/230-findings-notification-convergence/
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── findings-notification-convergence.logical.openapi.yaml
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── tasks.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
apps/platform/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ │ ├── Resources/
|
||||
│ │ │ └── FindingResource.php
|
||||
│ │ └── System/
|
||||
│ │ └── Pages/
|
||||
│ │ └── Ops/
|
||||
│ │ └── ViewRun.php
|
||||
│ ├── Notifications/
|
||||
│ │ ├── Findings/
|
||||
│ │ │ └── FindingEventNotification.php
|
||||
│ │ ├── OperationRunCompleted.php
|
||||
│ │ └── OperationRunQueued.php
|
||||
│ └── Support/
|
||||
│ ├── OperationRunLinks.php
|
||||
│ ├── OpsUx/
|
||||
│ │ └── OperationUxPresenter.php
|
||||
│ └── System/
|
||||
│ └── SystemOperationRunLinks.php
|
||||
└── tests/
|
||||
└── Feature/
|
||||
├── Findings/
|
||||
│ ├── FindingsNotificationEventTest.php
|
||||
│ └── FindingsNotificationRoutingTest.php
|
||||
├── Notifications/
|
||||
│ ├── FindingNotificationLinkTest.php
|
||||
│ ├── OperationRunNotificationTest.php
|
||||
│ └── SharedDatabaseNotificationContractTest.php
|
||||
└── OpsUx/
|
||||
└── Constitution/
|
||||
└── LegacyNotificationGuardTest.php
|
||||
```
|
||||
|
||||
**Structure Decision**: Standard Laravel monolith. The feature stays inside existing notification classes, presenter helpers, and Pest feature suites. No new base directory, no new panel, and no new persisted model are required.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| One bounded shared database-notification presentation contract | Three real consumers already exist and two still bypass the only shared presenter anchor | Separate local edits would preserve parallel primary grammars and keep the interaction family drifting |
|
||||
|
||||
## Proportionality Review
|
||||
|
||||
- **Current operator problem**: Operators see finding and operation notifications through different primary structures even though both are the same secondary context surface.
|
||||
- **Existing structure is insufficient because**: `OperationUxPresenter` currently covers only terminal operation notifications, while findings and queued operation notifications still compose their payloads locally.
|
||||
- **Narrowest correct implementation**: Extend the existing presenter seam into one bounded database-notification contract, align the current three real consumers, and stop there.
|
||||
- **Ownership cost created**: One shared presentation seam, small contract-level assertions, and maintenance of the domain-specific secondary metadata boundaries.
|
||||
- **Alternative intentionally rejected**: A universal notification platform, a new notification page, or consumer-by-consumer local tweaks. These either add premature infrastructure or fail to solve the shared-interaction drift.
|
||||
- **Release truth**: Current-release truth. This is a convergence change for already-existing operator-facing notifications.
|
||||
|
||||
## Phase 0 Research
|
||||
|
||||
Research outcomes are captured in `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/230-findings-notification-convergence/research.md`.
|
||||
|
||||
Key decisions:
|
||||
|
||||
- Reuse `OperationUxPresenter` as the shared anchor and extend it rather than creating a new notification framework.
|
||||
- Standardize the primary title, body, status, and action structure only; keep domain-specific metadata such as `finding_event` and `reason_translation` secondary and namespaced.
|
||||
- Preserve deep-link truth through `FindingResource::getUrl(...)`, `OperationRunLinks`, and `SystemOperationRunLinks` rather than rebuilding routes in notification classes.
|
||||
- Keep the existing Filament database-notification drawer, existing `notifications` table payload storage, and current deploy asset strategy unchanged.
|
||||
- Prove convergence through focused feature and guard tests instead of browser coverage, including an explicit guard on the FR-015 out-of-scope boundary.
|
||||
|
||||
## Phase 1 Design
|
||||
|
||||
Design artifacts are created under `/Users/ahmeddarrazi/Documents/projects/wt-plattform/specs/230-findings-notification-convergence/`:
|
||||
|
||||
- `research.md`: design decisions and rejected alternatives for the shared presentation seam
|
||||
- `data-model.md`: existing notification truth plus the derived shared presentation contract and consumer input shapes
|
||||
- `contracts/findings-notification-convergence.logical.openapi.yaml`: internal logical contract for shared database-notification presentation and the preserved destination routes
|
||||
- `quickstart.md`: focused implementation and review workflow
|
||||
|
||||
Design decisions:
|
||||
|
||||
- No schema migration is required; the feature only changes derived payload composition in the existing `notifications` table.
|
||||
- The canonical shared seam is an extension of the existing `OperationUxPresenter` path, not a new registry or interface family.
|
||||
- In-scope consumers retain one primary action and keep their current deep-link authority: tenant finding view, admin operation view, or system operation view.
|
||||
- Domain-specific metadata remains secondary and opt-in: findings keep `finding_event`, completed runs keep `reason_translation` and diagnostic fields, and queued runs keep only the minimal supporting context they already need.
|
||||
- Existing delivery semantics from Spec 224 and current operation notification behavior remain unchanged.
|
||||
|
||||
## Phase 1 Agent Context Update
|
||||
|
||||
Run:
|
||||
|
||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
## Constitution Check — Post-Design Re-evaluation
|
||||
|
||||
- PASS - the design stays inside current notification and presenter seams with no new persistence, no Graph work, no new capability family, and no new frontend assets.
|
||||
- PASS - Livewire v4.0+ and Filament v5 constraints remain satisfied, provider registration stays in `apps/platform/bootstrap/providers.php`, no globally searchable resource behavior changes, no destructive action is introduced, and the existing deploy step `cd apps/platform && php artisan filament:assets` remains unchanged.
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### Phase A - Define the shared database-notification presentation seam on the existing presenter anchor
|
||||
|
||||
**Goal**: Establish one bounded shared primary payload structure without creating a new framework.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| A.1 | `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` | Add one shared operator-facing database-notification builder path that standardizes title, body, status with the existing Filament icon treatment, one primary action, and optional supporting lines for the in-scope consumers |
|
||||
| A.2 | `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` | Preserve the current terminal run-specific presentation logic by feeding it into the shared builder rather than bypassing it |
|
||||
| A.3 | `apps/platform/app/Support/OpsUx/OperationUxPresenter.php` | Keep any extraction bounded to the existing namespace and avoid registries, factories, or a universal notification taxonomy |
|
||||
|
||||
### Phase B - Align findings notifications to the shared contract
|
||||
|
||||
**Goal**: Remove the local primary payload grammar from `FindingEventNotification` while preserving Spec 224 delivery and metadata semantics.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| B.1 | `apps/platform/app/Notifications/Findings/FindingEventNotification.php` | Replace the local `FilamentNotification::make()` primary payload build with the shared presentation seam |
|
||||
| B.2 | `apps/platform/app/Notifications/Findings/FindingEventNotification.php` | Preserve `FindingResource::getUrl('view', ...)` as the action target and keep `finding_event` metadata keys unchanged |
|
||||
| B.3 | `apps/platform/app/Notifications/Findings/FindingEventNotification.php` | Keep recipient-reason language secondary and subordinate to the shared primary body structure |
|
||||
|
||||
### Phase C - Align queued and terminal operation notifications to the same contract
|
||||
|
||||
**Goal**: Keep current operation notification semantics while eliminating the second local primary payload grammar.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| C.1 | `apps/platform/app/Notifications/OperationRunQueued.php` | Replace local payload composition with the shared contract while preserving current queued copy and current link-resolution rules |
|
||||
| C.2 | `apps/platform/app/Notifications/OperationRunCompleted.php` | Keep terminal presentation, summary lines, and reason-translation metadata, but route the primary card structure through the same shared contract |
|
||||
| C.3 | `apps/platform/app/Support/OperationRunLinks.php` and `apps/platform/app/Support/System/SystemOperationRunLinks.php` | Verify canonical action labels and plane-specific route generation remain authoritative, and apply only minimal normalization if the shared contract needs a common label accessor |
|
||||
|
||||
### Phase D - Preserve route and scope truth across tenant and system destinations
|
||||
|
||||
**Goal**: Ensure shared presentation does not flatten or widen current access rules.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| D.1 | `apps/platform/app/Notifications/Findings/FindingEventNotification.php` | Preserve tenant-panel finding detail routing and current `404` versus `403` behavior |
|
||||
| D.2 | `apps/platform/app/Notifications/OperationRunQueued.php` and `apps/platform/app/Notifications/OperationRunCompleted.php` | Preserve current admin-plane, tenantless, and platform-user system-plane route selection |
|
||||
| D.3 | Existing Filament notification shell | Keep the existing database-notification drawer as the only collection surface; do not add polling, a new page, or a second notification center |
|
||||
|
||||
### Phase E - Lock the shared contract with focused regression coverage
|
||||
|
||||
**Goal**: Make future local bypasses of the shared primary structure visible in CI.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| E.1 | `apps/platform/tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php` | Add direct contract-level assertions for the shared primary structure, including shared status-to-icon treatment, across findings, queued runs, and completed runs |
|
||||
| E.2 | `apps/platform/tests/Feature/Notifications/OperationRunNotificationTest.php` | Update queued and terminal operation notification tests to assert shared structure plus preserved route, status, and metadata behavior |
|
||||
| E.3 | `apps/platform/tests/Feature/Notifications/FindingNotificationLinkTest.php` | Update finding notification tests to assert shared structure, shared status semantics, and unchanged tenant-safe action behavior |
|
||||
| E.4 | `apps/platform/tests/Feature/Findings/FindingsNotificationEventTest.php` and `apps/platform/tests/Feature/Findings/FindingsNotificationRoutingTest.php` | Keep Spec 224 event and recipient semantics protected while the presentation path changes underneath them |
|
||||
| E.5 | `apps/platform/tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php` | Extend the guard so future in-scope notification consumers cannot silently bypass the shared primary presentation seam and so alert delivery, escalation, and `My Work` admission behavior stay outside this spec |
|
||||
|
||||
### Phase F - Validate formatting and the narrow proving set
|
||||
|
||||
**Goal**: Close the feature with the smallest executable proof set.
|
||||
|
||||
| Step | File | Change |
|
||||
|------|------|--------|
|
||||
| F.1 | `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` | Normalize style for touched PHP files |
|
||||
| F.2 | `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php` | Prove the shared contract and preserved destination semantics |
|
||||
| F.3 | `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php` | Prove no regression in Spec 224 behavior, no reintroduction of local notification bypasses, and no accidental expansion into alert or `My Work` behavior |
|
||||
@ -1,87 +0,0 @@
|
||||
# Quickstart: Findings Notification Presentation Convergence
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Start the local platform stack.
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail up -d
|
||||
```
|
||||
|
||||
2. Work with a workspace that has at least one tenant, one tenant operator, one platform user, one finding notification trigger, and one queued plus one completed operation run.
|
||||
|
||||
3. Remember that the feature changes only the existing Filament database-notification drawer. No new page or panel is expected during validation.
|
||||
|
||||
4. Filament database-notification polling is intentionally conservative in this repo, so reload or reopen the drawer after each trigger when validating manually.
|
||||
|
||||
## Automated Validation
|
||||
|
||||
Run formatting and the narrowest proving suites for this feature:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Notifications/SharedDatabaseNotificationContractTest.php tests/Feature/Notifications/OperationRunNotificationTest.php tests/Feature/Notifications/FindingNotificationLinkTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Findings/FindingsNotificationEventTest.php tests/Feature/Findings/FindingsNotificationRoutingTest.php tests/Feature/OpsUx/Constitution/LegacyNotificationGuardTest.php
|
||||
```
|
||||
|
||||
## Manual Validation Flow
|
||||
|
||||
### 1. Compare finding and operation notifications in the same drawer
|
||||
|
||||
1. Trigger one finding notification using an existing Spec 224 event.
|
||||
2. Trigger one queued operation notification and one completed operation notification.
|
||||
3. Reload the shell and open the database-notification drawer.
|
||||
4. Confirm all three cards show the same primary structure order:
|
||||
- title naming the target object
|
||||
- primary body summarizing the change
|
||||
- one status emphasis with the existing Filament icon treatment
|
||||
- exactly one primary action
|
||||
- optional supporting context beneath the primary body
|
||||
|
||||
### 2. Validate finding notification semantics remain unchanged
|
||||
|
||||
1. Open the finding notification card.
|
||||
2. Confirm the action label remains `Open finding`.
|
||||
3. Confirm the action opens the existing tenant finding detail page.
|
||||
4. Confirm recipient-reason language and finding severity remain visible only as supporting context or metadata, not as a replacement for the primary title or body.
|
||||
|
||||
### 3. Validate queued and completed operation notification semantics remain unchanged
|
||||
|
||||
1. Open the queued run notification card.
|
||||
2. Confirm it still communicates the queued state and opens the existing run destination for that notifiable context.
|
||||
3. Open the completed run notification card.
|
||||
4. Confirm terminal summary, failure guidance, and reason translation remain present as supporting context when applicable.
|
||||
|
||||
### 4. Validate tenant-plane and system-plane route truth
|
||||
|
||||
1. Open a finding notification as an entitled tenant user and confirm the tenant detail route opens.
|
||||
2. Open an operation notification as a workspace operator and confirm the current admin or tenantless run destination opens.
|
||||
3. Open an operation notification as a platform user and confirm the action resolves to `/system/ops/runs/{run}`.
|
||||
4. Confirm no shared presentation change causes the wrong route family to appear.
|
||||
|
||||
### 5. Validate authorization behavior did not change
|
||||
|
||||
1. Create a finding notification for an entitled tenant user.
|
||||
2. Remove that user’s tenant visibility or capability.
|
||||
3. Open the finding notification link and confirm the existing `404` versus `403` semantics remain authoritative.
|
||||
4. Repeat the same check for an operation notification where route visibility changes by plane or entitlement.
|
||||
|
||||
### 6. Validate no second shell or asset change appeared
|
||||
|
||||
1. Confirm the existing Filament notification drawer remains the only collection surface.
|
||||
2. Confirm no custom notification page, custom card shell, or new asset bundle appears.
|
||||
3. Confirm the feature does not require any new deploy step beyond the existing Filament assets pipeline.
|
||||
|
||||
### 7. Validate the FR-015 boundary stayed intact
|
||||
|
||||
1. Confirm no alert-rule routing, alert delivery, or escalation behavior changed as part of this feature.
|
||||
2. Confirm no `My Work` queue, admission rule, or dashboard clone was introduced.
|
||||
3. Confirm the only changed surface is the existing in-app database notification presentation for the in-scope consumers.
|
||||
|
||||
## Reviewer Notes
|
||||
|
||||
- The feature is Livewire v4.0+ compatible and stays on existing Filament v5 primitives.
|
||||
- Provider registration remains unchanged in `apps/platform/bootstrap/providers.php`.
|
||||
- No globally searchable resource behavior changes in this feature.
|
||||
- No new destructive action is introduced, so no new confirmation flow is required.
|
||||
- Asset strategy is unchanged: no new panel or shared assets, and the existing deploy `cd apps/platform && php artisan filament:assets` step remains sufficient.
|
||||
@ -1,67 +0,0 @@
|
||||
# Research: Findings Notification Presentation Convergence
|
||||
|
||||
## Decision 1: Extend the existing `OperationUxPresenter` seam instead of creating a notification framework
|
||||
|
||||
**Decision**: Reuse `App\Support\OpsUx\OperationUxPresenter` as the shared anchor for operator-facing database notification presentation and extend it with one bounded shared contract for the in-scope consumers.
|
||||
|
||||
**Rationale**: `OperationRunCompleted` already proves that the repository has one real shared presenter path for this interaction family. Extending that seam solves the current drift with the smallest change and follows XCUT-001 without introducing a new registry, interface family, or universal notification platform.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Create a new notification presenter package or registry. Rejected because only three real consumers are in scope and the spec explicitly forbids broader framework work.
|
||||
- Apply local copy changes in each notification class. Rejected because that would leave the shared interaction family without one explicit contract and would allow drift to continue.
|
||||
|
||||
## Decision 2: Standardize the primary structure only, not every metadata field
|
||||
|
||||
**Decision**: The shared contract will standardize title, body, one status emphasis with the existing Filament icon treatment, one primary action, and optional supporting lines. Existing domain-specific metadata remains namespaced and secondary.
|
||||
|
||||
**Rationale**: Findings notifications and operation notifications carry different domain truth. The operator needs one stable primary card structure, not one flattened metadata model. Keeping `finding_event`, `reason_translation`, and related diagnostic fields secondary preserves current domain fidelity without fragmenting the card grammar.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Normalize all notification metadata into one new envelope. Rejected because it adds unnecessary semantic machinery and risks losing useful domain-specific context.
|
||||
- Leave metadata and structure fully local. Rejected because primary structure drift is the operator problem this spec is solving.
|
||||
|
||||
## Decision 3: Preserve canonical deep-link helpers and plane-specific route resolution
|
||||
|
||||
**Decision**: Continue to source finding links from `FindingResource::getUrl(...)`, admin operation links from `OperationRunLinks`, and platform operation links from `SystemOperationRunLinks`.
|
||||
|
||||
**Rationale**: The feature is about presentation convergence, not route ownership. Existing helper seams already encode the correct tenant, admin, tenantless, and system-plane behavior, so the shared contract must consume them rather than replace them.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Build URLs inline inside the shared presenter. Rejected because it would duplicate existing routing truth and make access bugs more likely.
|
||||
- Collapse platform and admin operation destinations into one route family. Rejected because the spec requires the current plane-specific destination behavior to stay intact.
|
||||
|
||||
## Decision 4: Keep the existing Filament drawer and persistence artifacts unchanged
|
||||
|
||||
**Decision**: The feature will keep the current Filament database-notification drawer, the existing `notifications` table, and the current asset and polling behavior unchanged.
|
||||
|
||||
**Rationale**: The operator problem is inconsistent card grammar, not missing surfaces or storage. Reusing the current shell avoids scope creep and keeps the change inside the intended cleanup boundary.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Build a new notification center or work queue. Rejected because it adds a second collection surface that the spec explicitly forbids.
|
||||
- Add new persistence for shared presentation. Rejected because the shared contract is derived and does not represent new product truth.
|
||||
|
||||
## Decision 5: Prove convergence through focused feature and guard tests only
|
||||
|
||||
**Decision**: Add one contract-level notification test, extend current findings and operations notification tests, and update the existing legacy-notification guard for both local-bypass prevention and the preserved FR-015 boundary.
|
||||
|
||||
**Rationale**: The proving burden is payload structure and route safety across current consumers. Feature-level assertions already cover these concerns and are the narrowest executable proof.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Add browser coverage. Rejected because the Filament drawer shell itself is unchanged and browser cost would not prove more than payload-level feature tests.
|
||||
- Rely only on the legacy guard. Rejected because the guard can catch bypass patterns but cannot prove that the rendered payload structure stayed aligned across all current consumers.
|
||||
|
||||
## Decision 6: Preserve operation-completed guidance and reason translation as secondary context
|
||||
|
||||
**Decision**: `OperationRunCompleted` keeps its terminal presentation logic, summary lines, and `ReasonPresenter` output, but these remain secondary supporting context under the shared primary structure.
|
||||
|
||||
**Rationale**: Terminal run notifications carry more diagnostic signal than findings or queued runs. The shared contract should unify the card grammar without erasing the extra detail that makes completed-run notifications actionable.
|
||||
|
||||
**Alternatives considered**:
|
||||
|
||||
- Flatten completed-run detail into the same minimal body as queued notifications. Rejected because it would reduce useful operator signal on terminal outcomes.
|
||||
- Keep completed-run notifications fully separate because of richer detail. Rejected because that would leave the current interaction-family drift unresolved.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user