diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..fa8dd1ba4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,123 @@ +name: Create Release + +on: + push: + branches: + - 10.x + +jobs: + release: + runs-on: ubuntu-latest + if: github.event_name == 'push' && github.ref == 'refs/heads/10.x' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Install dependencies + run: composer install --no-dev --prefer-dist --no-interaction --optimize-autoloader + + - name: Run tests + run: | + composer require "laravel/framework:^11.0" "orchestra/testbench:^9.0" --no-interaction --no-update + composer update --prefer-stable --prefer-dist --no-interaction + vendor/bin/pest --ci + + - name: Get next version + id: get_version + run: | + # Get the latest tag (handle both v-prefixed and non-prefixed) + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "0.0.0") + echo "Latest tag: $LATEST_TAG" + + # Extract version numbers (remove 'v' prefix if present) + VERSION_NUM=${LATEST_TAG#v} + + # Split version into parts + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION_NUM" + + # Default to 0 if parts are empty + MAJOR=${MAJOR:-0} + MINOR=${MINOR:-0} + PATCH=${PATCH:-0} + + # Get the previous tag for commit analysis + PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + + # Analyze commits since last tag to determine version bump + if [ -z "$PREVIOUS_TAG" ]; then + # If no previous tags, analyze all commits + COMMITS=$(git log --pretty=format:"%s" --no-merges) + else + # Get commits since last tag + COMMITS=$(git log ${PREVIOUS_TAG}..HEAD --pretty=format:"%s" --no-merges) + fi + + echo "Analyzing commits:" + echo "$COMMITS" + + # Check for breaking changes (MAJOR version bump) + if echo "$COMMITS" | grep -E "^(BREAKING|BREAKING CHANGE|feat!|fix!):" > /dev/null; then + echo "Found breaking changes, incrementing MAJOR version" + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + # Check for features (MINOR version bump) + elif echo "$COMMITS" | grep -E "^feat(\(.+\))?:" > /dev/null; then + echo "Found features, incrementing MINOR version" + MINOR=$((MINOR + 1)) + PATCH=0 + # Default to patch version bump + else + echo "No features or breaking changes found, incrementing PATCH version" + PATCH=$((PATCH + 1)) + fi + + # Create new version (no v prefix) + NEW_VERSION="$MAJOR.$MINOR.$PATCH" + + echo "New version: $NEW_VERSION" + echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "tag_name=$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Generate changelog + id: changelog + run: | + # Get the previous tag for changelog + PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + + if [ -z "$PREVIOUS_TAG" ]; then + # If no previous tags, get all commits + COMMITS=$(git log --pretty=format:"* %s (%an)" --no-merges) + else + # Get commits since last tag + COMMITS=$(git log ${PREVIOUS_TAG}..HEAD --pretty=format:"* %s (%an)" --no-merges) + fi + + # Create changelog + CHANGELOG="## What's Changed\n\n$COMMITS" + + # Handle multiline output for GitHub Actions + echo "changelog<> $GITHUB_OUTPUT + echo -e "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create Release + uses: softprops/action-gh-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.get_version.outputs.tag_name }} + name: Release ${{ steps.get_version.outputs.tag_name }} + body: ${{ steps.changelog.outputs.changelog }} + draft: false + prerelease: false diff --git a/docs-v2/content/en/api/fields.md b/docs-v2/content/en/api/fields.md index b4a817cce..c2b795402 100644 --- a/docs-v2/content/en/api/fields.md +++ b/docs-v2/content/en/api/fields.md @@ -1065,6 +1065,44 @@ The MCP visibility system automatically detects when a request is coming from an This allows you to have different field visibility for your regular API consumers versus AI agents accessing your data through MCP tools. +### Field Descriptions + +Fields can have custom descriptions that are used when generating schema documentation, particularly useful for MCP tools and API documentation: + +```php +public function fields(RestifyRequest $request) +{ + return [ + field('status') + ->description('The current status of the item') + ->rules(['required', 'string']), + + field('feedbackable_id') + ->description('This is the id of the employee.') + ->rules(['required', 'string', 'max:26']), + + field('priority') + ->description(function($generatedDescription, $field, $repository) { + return $generatedDescription . ' - Values range from 1 (low) to 5 (high)'; + }), + ]; +} +``` + +The `description()` method accepts either: +- **String**: A static description text +- **Closure**: A callback that receives the auto-generated description, field instance, and repository for dynamic modifications + +When using a closure, you can: +- Modify the automatically generated description +- Add context-specific information +- Access field and repository data for dynamic descriptions + +The description callback receives three parameters: +- `$generatedDescription` - The automatically generated description based on field type and validation rules +- `$field` - The field instance +- `$repository` - The repository context + ### Custom Tool Schema When using MCP, you can define custom schema definitions for individual fields using the `toolSchema()` method: diff --git a/src/Fields/Field.php b/src/Fields/Field.php index 5cb019047..c1b1f7914 100644 --- a/src/Fields/Field.php +++ b/src/Fields/Field.php @@ -145,6 +145,11 @@ class Field extends OrganicField implements JsonSerializable, Matchable, Sortabl public $toolInputSchemaCallback = null; + /** + * Closure to modify the generated field description. + */ + public $descriptionCallback = null; + /** * Create a new field. * @@ -937,4 +942,22 @@ public function toolSchema(callable|Closure $callback): self return $this; } + + /** + * Set a callback to modify the generated field description. + * + * @return $this + */ + public function description(string|callable|Closure $callback): self + { + if (is_string($callback)) { + $this->descriptionCallback = fn () => $callback; + + return $this; + } + + $this->descriptionCallback = $callback; + + return $this; + } } diff --git a/src/MCP/Concerns/FieldMcpSchemaDetection.php b/src/MCP/Concerns/FieldMcpSchemaDetection.php index b80c17a9e..929333631 100644 --- a/src/MCP/Concerns/FieldMcpSchemaDetection.php +++ b/src/MCP/Concerns/FieldMcpSchemaDetection.php @@ -83,6 +83,11 @@ protected function generateFieldDescription(Repository $repository): string $description .= '. Examples: '.implode(', ', $examples); } + // Apply custom description callback if provided + if (is_callable($this->descriptionCallback)) { + $description = call_user_func($this->descriptionCallback, $description, $this, $repository); + } + return $description; } diff --git a/tests/Fields/FieldMcpSchemaDetectionTest.php b/tests/Fields/FieldMcpSchemaDetectionTest.php index ab5d8a7d3..bd6f148b5 100644 --- a/tests/Fields/FieldMcpSchemaDetectionTest.php +++ b/tests/Fields/FieldMcpSchemaDetectionTest.php @@ -65,6 +65,40 @@ public function test_resolve_tool_schema_with_custom_callback(): void $this->assertSame($field, $result); } + public function test_resolve_tool_schema_with_string_description(): void + { + $schema = Mockery::mock(ToolInputSchema::class); + $repository = new PostRepository; + + $schema->shouldReceive('string')->with('title')->once()->andReturnSelf(); + $schema->shouldReceive('description')->with('Custom description for the title field')->once()->andReturnSelf(); + + $field = $this->createTestField('title'); + $field->description('Custom description for the title field'); + + $result = $field->resolveToolSchema($schema, $repository); + + $this->assertSame($field, $result); + } + + public function test_resolve_tool_schema_with_closure_description(): void + { + $schema = Mockery::mock(ToolInputSchema::class); + $repository = new PostRepository; + + $schema->shouldReceive('string')->with('title')->once()->andReturnSelf(); + $schema->shouldReceive('description')->with('Field: title (type: string). Examples: Sample Title, My Title - Custom addition')->once()->andReturnSelf(); + + $field = $this->createTestField('title'); + $field->description(function ($generatedDescription, $field, $repository) { + return $generatedDescription.' - Custom addition'; + }); + + $result = $field->resolveToolSchema($schema, $repository); + + $this->assertSame($field, $result); + } + public function test_get_string_examples_for_different_contexts(): void { $field = $this->createTestField('email'); @@ -107,6 +141,10 @@ protected function createTestField(string $attribute, array $rules = []): Field $field->shouldReceive('generateFieldExamples')->passthru(); $field->shouldReceive('getNumberExamples')->passthru(); $field->shouldReceive('getStringExamples')->passthru(); + $field->shouldReceive('description')->passthru(); + + // Initialize the descriptionCallback property + $field->descriptionCallback = null; return $field; }