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
|
||||
"""
|
||||
9
.github/agents/copilot-instructions.md
vendored
9
.github/agents/copilot-instructions.md
vendored
@ -238,10 +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, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers (232-operation-run-link-contract)
|
||||
- PostgreSQL-backed existing `operation_runs`, `tenants`, and `workspaces` records plus current session-backed canonical navigation state; no new persistence (232-operation-run-link-contract)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -276,9 +272,10 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 232-operation-run-link-contract: Added PHP 8.4.15, Laravel 12, Filament v5, Livewire v4 + Filament Resources/Pages/Widgets, Pest v4, `App\Support\OperationRunLinks`, `App\Support\System\SystemOperationRunLinks`, `App\Support\Navigation\CanonicalNavigationContext`, `App\Support\Navigation\RelatedNavigationResolver`, existing workspace and tenant authorization helpers
|
||||
- 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -20,7 +20,6 @@
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Inventory\TenantCoverageTruth;
|
||||
use App\Support\Inventory\TenantCoverageTruthResolver;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -536,7 +535,7 @@ public function basisRunSummary(): array
|
||||
: 'The coverage basis is current, but your role cannot open the cited run detail.',
|
||||
'badgeLabel' => $badge->label,
|
||||
'badgeColor' => $badge->color,
|
||||
'runUrl' => $canViewRun ? OperationRunLinks::view($truth->basisRun, $tenant) : null,
|
||||
'runUrl' => $canViewRun ? route('admin.operations.view', ['run' => (int) $truth->basisRun->getKey()]) : null,
|
||||
'historyUrl' => $canViewRun ? $this->inventorySyncHistoryUrl($tenant) : null,
|
||||
'inventoryItemsUrl' => InventoryItemResource::getUrl('index', panel: 'tenant', tenant: $tenant),
|
||||
];
|
||||
@ -561,6 +560,13 @@ protected function coverageTruth(): ?TenantCoverageTruth
|
||||
|
||||
private function inventorySyncHistoryUrl(Tenant $tenant): string
|
||||
{
|
||||
return OperationRunLinks::index($tenant, operationType: 'inventory_sync');
|
||||
return route('admin.operations.index', [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'tableFilters' => [
|
||||
'type' => [
|
||||
'value' => 'inventory_sync',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,14 +110,14 @@ protected function getHeaderActions(): array
|
||||
$actions[] = Action::make('operate_hub_back_to_operations')
|
||||
->label('Back to Operations')
|
||||
->color('gray')
|
||||
->url(fn (): string => OperationRunLinks::index());
|
||||
->url(fn (): string => route('admin.operations.index'));
|
||||
}
|
||||
|
||||
if ($activeTenant instanceof Tenant) {
|
||||
$actions[] = Action::make('operate_hub_show_all_operations')
|
||||
->label('Show all operations')
|
||||
->color('gray')
|
||||
->url(fn (): string => OperationRunLinks::index());
|
||||
->url(fn (): string => route('admin.operations.index'));
|
||||
}
|
||||
|
||||
$actions[] = Action::make('refresh')
|
||||
@ -126,7 +126,7 @@ protected function getHeaderActions(): array
|
||||
->color('primary')
|
||||
->url(fn (): string => isset($this->run)
|
||||
? OperationRunLinks::tenantlessView($this->run, $navigationContext)
|
||||
: OperationRunLinks::index());
|
||||
: route('admin.operations.index'));
|
||||
|
||||
if (! isset($this->run)) {
|
||||
return $actions;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -17,7 +17,6 @@
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\Filament\FilterOptionCatalog;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
@ -149,13 +148,7 @@ public static function infolist(Schema $schema): Schema
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return OperationRunLinks::view((int) $record->last_seen_operation_run_id, $tenant);
|
||||
}
|
||||
|
||||
return OperationRunLinks::tenantlessView((int) $record->last_seen_operation_run_id);
|
||||
return route('admin.operations.view', ['run' => (int) $record->last_seen_operation_run_id]);
|
||||
})
|
||||
->openUrlInNewTab(),
|
||||
TextEntry::make('support_restore')
|
||||
|
||||
@ -13,7 +13,6 @@
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
use App\Support\ReviewPackStatus;
|
||||
@ -200,19 +199,9 @@ public static function infolist(Schema $schema): Schema
|
||||
->placeholder('—'),
|
||||
TextEntry::make('operationRun.id')
|
||||
->label('Operation')
|
||||
->url(function (ReviewPack $record): ?string {
|
||||
if (! $record->operation_run_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$tenant = $record->tenant;
|
||||
|
||||
if ($tenant instanceof Tenant) {
|
||||
return OperationRunLinks::view((int) $record->operation_run_id, $tenant);
|
||||
}
|
||||
|
||||
return OperationRunLinks::tenantlessView((int) $record->operation_run_id);
|
||||
})
|
||||
->url(fn (ReviewPack $record): ?string => $record->operation_run_id
|
||||
? route('admin.operations.view', ['run' => (int) $record->operation_run_id])
|
||||
: null)
|
||||
->openUrlInNewTab()
|
||||
->placeholder('—'),
|
||||
TextEntry::make('fingerprint')->label('Fingerprint')->copyable()->placeholder('—'),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,7 +41,7 @@ protected function getViewData(): array
|
||||
return [
|
||||
'tenant' => null,
|
||||
'runs' => collect(),
|
||||
'operationsIndexUrl' => OperationRunLinks::index(),
|
||||
'operationsIndexUrl' => route('admin.operations.index'),
|
||||
'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(),
|
||||
'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(),
|
||||
];
|
||||
@ -68,7 +68,7 @@ protected function getViewData(): array
|
||||
return [
|
||||
'tenant' => $tenant,
|
||||
'runs' => $runs,
|
||||
'operationsIndexUrl' => OperationRunLinks::index($tenant),
|
||||
'operationsIndexUrl' => route('admin.operations.index'),
|
||||
'operationsIndexLabel' => OperationRunLinks::openCollectionLabel(),
|
||||
'operationsIndexDescription' => OperationRunLinks::collectionScopeDescription(),
|
||||
];
|
||||
|
||||
@ -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>
|
||||
*/
|
||||
|
||||
@ -6,89 +6,19 @@
|
||||
|
||||
class PanelThemeAsset
|
||||
{
|
||||
/**
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
private static array $hotAssetReachability = [];
|
||||
|
||||
public static function resolve(string $entry): ?string
|
||||
{
|
||||
if (app()->runningUnitTests()) {
|
||||
return static::resolveFromManifest($entry);
|
||||
}
|
||||
|
||||
if (static::shouldUseHotAsset($entry)) {
|
||||
if (is_file(public_path('hot'))) {
|
||||
return Vite::asset($entry);
|
||||
}
|
||||
|
||||
return static::resolveFromManifest($entry);
|
||||
}
|
||||
|
||||
private static function shouldUseHotAsset(string $entry): bool
|
||||
{
|
||||
$hotFile = public_path('hot');
|
||||
|
||||
if (! is_file($hotFile)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$hotUrl = trim((string) file_get_contents($hotFile));
|
||||
|
||||
if ($hotUrl === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$assetUrl = Vite::asset($entry);
|
||||
|
||||
if ($assetUrl === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (array_key_exists($assetUrl, static::$hotAssetReachability)) {
|
||||
return static::$hotAssetReachability[$assetUrl];
|
||||
}
|
||||
|
||||
$parts = parse_url($assetUrl);
|
||||
|
||||
if (! is_array($parts)) {
|
||||
return static::$hotAssetReachability[$assetUrl] = false;
|
||||
}
|
||||
|
||||
$host = $parts['host'] ?? null;
|
||||
|
||||
if (! is_string($host) || $host === '') {
|
||||
return static::$hotAssetReachability[$assetUrl] = false;
|
||||
}
|
||||
|
||||
$scheme = $parts['scheme'] ?? 'http';
|
||||
$port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80);
|
||||
$transport = $scheme === 'https' ? 'ssl://' : '';
|
||||
$connection = @fsockopen($transport.$host, $port, $errorNumber, $errorMessage, 0.2);
|
||||
|
||||
if (! is_resource($connection)) {
|
||||
return static::$hotAssetReachability[$assetUrl] = false;
|
||||
}
|
||||
|
||||
$path = ($parts['path'] ?? '/').(isset($parts['query']) ? '?'.$parts['query'] : '');
|
||||
$hostHeader = isset($parts['port']) ? $host.':'.$port : $host;
|
||||
|
||||
stream_set_timeout($connection, 0, 200000);
|
||||
fwrite(
|
||||
$connection,
|
||||
"HEAD {$path} HTTP/1.1\r\nHost: {$hostHeader}\r\nConnection: close\r\n\r\n",
|
||||
);
|
||||
|
||||
$statusLine = fgets($connection);
|
||||
|
||||
fclose($connection);
|
||||
|
||||
if (! is_string($statusLine)) {
|
||||
return static::$hotAssetReachability[$assetUrl] = false;
|
||||
}
|
||||
|
||||
return static::$hotAssetReachability[$assetUrl] = preg_match('/^HTTP\/\d\.\d\s+[23]\d\d\b/', $statusLine) === 1;
|
||||
}
|
||||
|
||||
private static function resolveFromManifest(string $entry): ?string
|
||||
{
|
||||
$manifest = public_path('build/manifest.json');
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -202,7 +202,7 @@ public function auditTargetLink(AuditLog $record): ?array
|
||||
->whereKey($resourceId)
|
||||
->where('workspace_id', (int) $workspace->getKey())
|
||||
->exists()
|
||||
? ['label' => OperationRunLinks::openLabel(), 'url' => OperationRunLinks::tenantlessView($resourceId)]
|
||||
? ['label' => OperationRunLinks::openLabel(), 'url' => route('admin.operations.view', ['run' => $resourceId])]
|
||||
: null,
|
||||
'baseline_profile' => $workspace instanceof Workspace
|
||||
&& $this->workspaceCapabilityResolver->isMember($user, $workspace)
|
||||
|
||||
@ -81,7 +81,6 @@ public static function index(
|
||||
?string $activeTab = null,
|
||||
bool $allTenants = false,
|
||||
?string $problemClass = null,
|
||||
?string $operationType = null,
|
||||
): string {
|
||||
$parameters = $context?->toQuery() ?? [];
|
||||
|
||||
@ -107,10 +106,6 @@ public static function index(
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($operationType) && $operationType !== '') {
|
||||
$parameters['tableFilters']['type']['value'] = $operationType;
|
||||
}
|
||||
|
||||
return route('admin.operations.index', $parameters);
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -7,7 +7,6 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@ -64,7 +63,7 @@ public function test_shows_only_generic_links_for_tenantless_runs_on_canonical_d
|
||||
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
|
||||
->assertOk()
|
||||
->assertSee('Operations')
|
||||
->assertSee(OperationRunLinks::index(), false)
|
||||
->assertSee(route('admin.operations.index'), false)
|
||||
->assertDontSee('View restore run');
|
||||
}
|
||||
|
||||
|
||||
@ -75,34 +75,6 @@ public function test_trusts_notification_style_run_links_with_no_selected_tenant
|
||||
->assertSee('Canonical workspace view');
|
||||
}
|
||||
|
||||
public function test_uses_canonical_collection_link_for_default_back_and_show_all_fallbacks(): void
|
||||
{
|
||||
$runTenant = Tenant::factory()->create();
|
||||
[$user, $runTenant] = createUserWithTenant(tenant: $runTenant, role: 'owner');
|
||||
|
||||
$otherTenant = Tenant::factory()->create([
|
||||
'workspace_id' => (int) $runTenant->workspace_id,
|
||||
]);
|
||||
|
||||
createUserWithTenant(tenant: $otherTenant, user: $user, role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $runTenant->workspace_id,
|
||||
'tenant_id' => (int) $runTenant->getKey(),
|
||||
'type' => 'inventory_sync',
|
||||
]);
|
||||
|
||||
Filament::setTenant($otherTenant, true);
|
||||
|
||||
$this->actingAs($user)
|
||||
->withSession([WorkspaceContext::SESSION_KEY => (int) $runTenant->workspace_id])
|
||||
->get(OperationRunLinks::tenantlessView($run))
|
||||
->assertOk()
|
||||
->assertSee('Back to Operations')
|
||||
->assertSee('Show all operations')
|
||||
->assertSee(OperationRunLinks::index(), false);
|
||||
}
|
||||
|
||||
public function test_trusts_verification_surface_run_links_with_no_selected_tenant_context(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -4,11 +4,9 @@
|
||||
|
||||
use App\Filament\Pages\InventoryCoverage;
|
||||
use App\Filament\Resources\InventoryItemResource;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@ -42,14 +40,21 @@ function seedCoverageBasisRun(Tenant $tenant): OperationRun
|
||||
|
||||
$run = seedCoverageBasisRun($tenant);
|
||||
|
||||
$historyUrl = OperationRunLinks::index($tenant, operationType: 'inventory_sync');
|
||||
$historyUrl = route('admin.operations.index', [
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'tableFilters' => [
|
||||
'type' => [
|
||||
'value' => 'inventory_sync',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(InventoryCoverage::getUrl(tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Latest coverage-bearing sync completed')
|
||||
->assertSee('Open basis run')
|
||||
->assertSee(OperationRunLinks::view($run, $tenant), false)
|
||||
->assertSee(route('admin.operations.view', ['run' => (int) $run->getKey()]), false)
|
||||
->assertSee($historyUrl, false)
|
||||
->assertSee('Review the cited inventory sync to inspect provider or permission issues in detail.');
|
||||
});
|
||||
@ -73,26 +78,6 @@ function seedCoverageBasisRun(Tenant $tenant): OperationRun
|
||||
->assertDontSee('Open basis run');
|
||||
});
|
||||
|
||||
it('shows the last inventory sync as a canonical admin operation detail link', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'type' => 'inventory_sync',
|
||||
]);
|
||||
|
||||
$item = InventoryItem::factory()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'last_seen_operation_run_id' => (int) $run->getKey(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Last inventory sync')
|
||||
->assertSee(OperationRunLinks::view($run, $tenant), false);
|
||||
});
|
||||
|
||||
it('keeps the no-basis fallback explicit on the inventory items list', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$run = OperationRun::factory()->create([
|
||||
OperationRun::factory()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'type' => 'provider.connection.check',
|
||||
@ -32,8 +32,6 @@
|
||||
->assertSee('Open operation')
|
||||
->assertSee(OperationRunLinks::openCollectionLabel())
|
||||
->assertSee(OperationRunLinks::collectionScopeDescription())
|
||||
->assertSee(OperationRunLinks::index($tenant), false)
|
||||
->assertSee(OperationRunLinks::tenantlessView($run), false)
|
||||
->assertSee('No action needed.')
|
||||
->assertDontSee('No operations yet.');
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -1,160 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Tests\Support\OpsUx\SourceFileScanner;
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function operationRunLinkContractIncludePaths(): array
|
||||
{
|
||||
$root = SourceFileScanner::projectRoot();
|
||||
|
||||
return [
|
||||
'tenant_recent_operations_summary' => $root.'/app/Filament/Widgets/Tenant/RecentOperationsSummary.php',
|
||||
'inventory_coverage' => $root.'/app/Filament/Pages/InventoryCoverage.php',
|
||||
'inventory_item_resource' => $root.'/app/Filament/Resources/InventoryItemResource.php',
|
||||
'review_pack_resource' => $root.'/app/Filament/Resources/ReviewPackResource.php',
|
||||
'tenantless_operation_run_viewer' => $root.'/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php',
|
||||
'related_navigation_resolver' => $root.'/app/Support/Navigation/RelatedNavigationResolver.php',
|
||||
'system_directory_tenant' => $root.'/app/Filament/System/Pages/Directory/ViewTenant.php',
|
||||
'system_directory_workspace' => $root.'/app/Filament/System/Pages/Directory/ViewWorkspace.php',
|
||||
'system_ops_runs' => $root.'/app/Filament/System/Pages/Ops/Runs.php',
|
||||
'system_ops_view_run' => $root.'/app/Filament/System/Pages/Ops/ViewRun.php',
|
||||
'admin_panel_provider' => $root.'/app/Providers/Filament/AdminPanelProvider.php',
|
||||
'tenant_panel_provider' => $root.'/app/Providers/Filament/TenantPanelProvider.php',
|
||||
'ensure_filament_tenant_selected' => $root.'/app/Support/Middleware/EnsureFilamentTenantSelected.php',
|
||||
'clear_tenant_context_controller' => $root.'/app/Http/Controllers/ClearTenantContextController.php',
|
||||
'operation_run_url_delegate' => $root.'/app/Support/OpsUx/OperationRunUrl.php',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
function operationRunLinkContractAllowlist(): array
|
||||
{
|
||||
$paths = operationRunLinkContractIncludePaths();
|
||||
|
||||
return [
|
||||
$paths['admin_panel_provider'] => 'Admin panel navigation is bootstrapping infrastructure and intentionally links to the canonical collection route before request-scoped navigation context exists.',
|
||||
$paths['tenant_panel_provider'] => 'Tenant panel navigation is bootstrapping infrastructure and intentionally links to the canonical collection route before tenant-specific helper context is owned by the source surface.',
|
||||
$paths['ensure_filament_tenant_selected'] => 'Tenant-selection middleware owns redirect/navigation fallback infrastructure and must not fabricate source-surface navigation context.',
|
||||
$paths['clear_tenant_context_controller'] => 'Clear-tenant redirects preserve an explicit redirect contract and cannot depend on UI helper context.',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $paths
|
||||
* @param array<string, string> $allowlist
|
||||
* @return list<array{file: string, line: int, snippet: string, expectedHelper: string, reason: string}>
|
||||
*/
|
||||
function operationRunLinkContractViolations(array $paths, array $allowlist = []): array
|
||||
{
|
||||
$patterns = [
|
||||
[
|
||||
'pattern' => '/route\(\s*[\'"]admin\.operations\.index[\'"]/',
|
||||
'expectedHelper' => 'OperationRunLinks::index(...)',
|
||||
'reason' => 'Raw admin operations collection route assembly bypasses the canonical admin link helper.',
|
||||
],
|
||||
[
|
||||
'pattern' => '/route\(\s*[\'"]admin\.operations\.view[\'"]/',
|
||||
'expectedHelper' => 'OperationRunLinks::view(...) or OperationRunLinks::tenantlessView(...)',
|
||||
'reason' => 'Raw admin operation detail route assembly bypasses the canonical admin link helper.',
|
||||
],
|
||||
[
|
||||
'pattern' => '/[\'"]\/system\/ops\/runs(?:\/[^\'"]*)?[\'"]/',
|
||||
'expectedHelper' => 'SystemOperationRunLinks::index() or SystemOperationRunLinks::view(...)',
|
||||
'reason' => 'Direct system operations path assembly bypasses the canonical system link helper.',
|
||||
],
|
||||
[
|
||||
'pattern' => '/\b(?:Runs|ViewRun)::getUrl\(/',
|
||||
'expectedHelper' => 'SystemOperationRunLinks::index() or SystemOperationRunLinks::view(...)',
|
||||
'reason' => 'Direct system operations page URL generation belongs behind the canonical system link helper.',
|
||||
],
|
||||
];
|
||||
|
||||
$violations = [];
|
||||
|
||||
foreach ($paths as $path) {
|
||||
if (array_key_exists($path, $allowlist)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = SourceFileScanner::read($path);
|
||||
$lines = preg_split('/\R/', $source) ?: [];
|
||||
|
||||
foreach ($lines as $index => $line) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match($pattern['pattern'], $line) !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$violations[] = [
|
||||
'file' => SourceFileScanner::relativePath($path),
|
||||
'line' => $index + 1,
|
||||
'snippet' => SourceFileScanner::snippet($source, $index + 1),
|
||||
'expectedHelper' => $pattern['expectedHelper'],
|
||||
'reason' => $pattern['reason'],
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $violations;
|
||||
}
|
||||
|
||||
it('keeps covered operation run link producers on canonical helper families', function (): void {
|
||||
$paths = operationRunLinkContractIncludePaths();
|
||||
$allowlist = operationRunLinkContractAllowlist();
|
||||
|
||||
$violations = operationRunLinkContractViolations($paths, $allowlist);
|
||||
|
||||
expect($violations)->toBeEmpty();
|
||||
})->group('surface-guard');
|
||||
|
||||
it('keeps the operation run link exception boundary explicit and infrastructure-owned', function (): void {
|
||||
$allowlist = operationRunLinkContractAllowlist();
|
||||
|
||||
expect(array_keys($allowlist))->toHaveCount(4);
|
||||
|
||||
foreach ($allowlist as $reason) {
|
||||
expect($reason)
|
||||
->not->toBe('')
|
||||
->not->toContain('convenience');
|
||||
}
|
||||
|
||||
foreach (array_keys($allowlist) as $path) {
|
||||
expect(SourceFileScanner::read($path))->toContain("route('admin.operations.index')");
|
||||
}
|
||||
})->group('surface-guard');
|
||||
|
||||
it('reports actionable file and snippet output for a representative raw bypass', function (): void {
|
||||
$probePath = storage_path('framework/testing/OperationRunLinkContractProbe.php');
|
||||
|
||||
if (! is_dir(dirname($probePath))) {
|
||||
mkdir(dirname($probePath), 0777, true);
|
||||
}
|
||||
|
||||
file_put_contents($probePath, <<<'PHP'
|
||||
<?php
|
||||
|
||||
return route('admin.operations.view', ['run' => 123]);
|
||||
PHP);
|
||||
|
||||
try {
|
||||
$violations = operationRunLinkContractViolations([
|
||||
'probe' => $probePath,
|
||||
]);
|
||||
} finally {
|
||||
@unlink($probePath);
|
||||
}
|
||||
|
||||
expect($violations)->toHaveCount(1)
|
||||
->and($violations[0]['file'])->toContain('OperationRunLinkContractProbe.php')
|
||||
->and($violations[0]['line'])->toBe(3)
|
||||
->and($violations[0]['snippet'])->toContain("route('admin.operations.view'")
|
||||
->and($violations[0]['expectedHelper'])->toContain('OperationRunLinks::view')
|
||||
->and($violations[0]['reason'])->toContain('bypasses the canonical admin link helper');
|
||||
})->group('surface-guard');
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user