chore: import upstream snapshot with attribution
This commit is contained in:
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.claude/skills
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "mlflow-plugins",
|
||||
"owner": {
|
||||
"name": "MLflow"
|
||||
},
|
||||
"description": "MLflow tracing and observability plugins for Claude Code.",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "mlflow-tracing",
|
||||
"source": {
|
||||
"source": "npm",
|
||||
"package": "@mlflow/claude-code"
|
||||
},
|
||||
"description": "Observability plugin for Claude Code with MLflow tracing",
|
||||
"homepage": "https://mlflow.org/",
|
||||
"repository": "https://github.com/mlflow/mlflow",
|
||||
"license": "Apache-2.0",
|
||||
"category": "developer-tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Hook to enforce using 'uv' instead of 'python', 'python3', 'pip', or 'pip3' directly.
|
||||
# This ensures consistent virtual environment usage across the project.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Skip if jq is unavailable
|
||||
if ! command -v jq &>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip if uv is unavailable
|
||||
if ! command -v uv &>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read hook input from stdin
|
||||
input=$(cat)
|
||||
|
||||
# Extract tool name and command
|
||||
tool_name=$(echo "$input" | jq -r '.tool_name // empty')
|
||||
command=$(echo "$input" | jq -r '.tool_input.command // empty')
|
||||
|
||||
# Only process Bash tool calls
|
||||
if [[ "$tool_name" != "Bash" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Block direct python/python3 commands, regardless of their full path
|
||||
deny_reason=""
|
||||
|
||||
if echo "$command" | head -1 | grep -qE '^([^[:space:]]*/)?python3?[[:space:]]'; then
|
||||
deny_reason="Direct python/python3 execution detected. Use 'uv run' instead."
|
||||
fi
|
||||
|
||||
# Block direct pip/pip3 commands
|
||||
if echo "$command" | head -1 | grep -qE '^([^[:space:]]*/)?pip3?[[:space:]]'; then
|
||||
deny_reason="Direct pip/pip3 execution detected. Use 'uv pip' or 'uv run --with <package>' (for one-off usage without permanent install) instead."
|
||||
fi
|
||||
|
||||
# Emit deny decision if a reason was set
|
||||
if [[ -n "$deny_reason" ]]; then
|
||||
echo "{
|
||||
\"hookSpecificOutput\": {
|
||||
\"hookEventName\": \"PreToolUse\",
|
||||
\"permissionDecision\": \"deny\",
|
||||
\"permissionDecisionReason\": \"$deny_reason\"
|
||||
}
|
||||
}"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
"""Hook to validate that `gh pr create` includes all sections from the PR template."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_headings() -> list[str]:
|
||||
"""Parse heading sections (#, ##, ###, etc.) from the PR template."""
|
||||
pr_template = Path(__file__).resolve().parents[2] / ".github" / "pull_request_template.md"
|
||||
lines = (l.strip() for l in pr_template.read_text().splitlines())
|
||||
return [l for l in lines if re.match(r"^#+\s", l)]
|
||||
|
||||
|
||||
def deny(reason: str) -> None:
|
||||
json.dump(
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": reason,
|
||||
}
|
||||
},
|
||||
sys.stdout,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
input_data = json.loads(sys.stdin.read())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return
|
||||
|
||||
match input_data:
|
||||
case {
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {
|
||||
"command": str(command),
|
||||
},
|
||||
}:
|
||||
pass
|
||||
case _:
|
||||
return
|
||||
|
||||
if not re.search(r"gh\s+pr\s+create\b", command):
|
||||
return
|
||||
|
||||
# Skip commands without a body (e.g. --help) or with --body-file / -F
|
||||
# (we can't validate file contents from the command string)
|
||||
if re.search(r"(--body-file\b|-F\b)", command):
|
||||
return
|
||||
if not re.search(r"(--body\b|-b\b)", command):
|
||||
return
|
||||
|
||||
# Check section headings against the command lines rather than parsing the
|
||||
# body out. The headings (e.g. "### How is this PR tested?") are unique
|
||||
# enough that they won't appear in other flags like --title or --repo, so the
|
||||
# risk of false positives is negligible.
|
||||
headings = get_headings()
|
||||
command_lines = {line.strip() for line in command.splitlines()}
|
||||
missing = [s for s in headings if s not in command_lines]
|
||||
|
||||
# If all sections are missing, the body is likely opaque (e.g. --body "$VAR")
|
||||
# and we can't validate it. Only deny when some sections are present but
|
||||
# others are missing, indicating an incomplete but visible body.
|
||||
if missing and len(missing) < len(headings):
|
||||
missing_list = "\n".join(f" - {s}" for s in missing)
|
||||
deny(
|
||||
f"PR body is missing required sections from the PR template:\n"
|
||||
f"{missing_list}\n"
|
||||
f"Please include all sections from .github/pull_request_template.md."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
paths: ".github/workflows/**/*.yml"
|
||||
---
|
||||
|
||||
# GitHub Actions Workflow Guidelines
|
||||
|
||||
## Use `ubuntu-slim` for Lightweight Tasks
|
||||
|
||||
Prefer `ubuntu-slim` over `ubuntu-latest` for simple jobs (e.g., labeling, commenting, notifications).
|
||||
|
||||
Note: `ubuntu-slim` has a 15-minute timeout limit. Use `ubuntu-latest` for long-running jobs (e.g., polling).
|
||||
|
||||
```yaml
|
||||
# Bad
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Good
|
||||
runs-on: ubuntu-slim
|
||||
```
|
||||
|
||||
## Use Workflow Context Instead of Fetching
|
||||
|
||||
If the trigger event already carries the data, read it from the `github` context instead of calling `gh` or `actions/github-script`. Extra API calls burn rate-limit budget and add a flaky network hop for nothing.
|
||||
|
||||
```yaml
|
||||
# Bad
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
HEAD_SHA=$(gh pr view "$PR_NUMBER" --json headRefOid -q .headRefOid)
|
||||
|
||||
# Good
|
||||
- env:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: echo "$HEAD_SHA"
|
||||
```
|
||||
|
||||
Only fetch when the data isn't in the payload (e.g., check runs, review threads, changed files on `issue_comment`).
|
||||
|
||||
## Prefer `gh` CLI over `actions/github-script`
|
||||
|
||||
For simple GitHub API operations (commenting, labeling, cancelling runs, etc.),
|
||||
use `gh` CLI instead of `actions/github-script`. It avoids the need for
|
||||
`actions/checkout` and JavaScript boilerplate.
|
||||
|
||||
```yaml
|
||||
# Bad
|
||||
- uses: actions/checkout@...
|
||||
- uses: actions/github-script@...
|
||||
with:
|
||||
script: |
|
||||
const script = require(".github/workflows/my-script.js");
|
||||
await script({ context, github });
|
||||
|
||||
# Good
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr comment ...
|
||||
```
|
||||
|
||||
## Use `sparse-checkout` When Only a Subset of Files Is Needed
|
||||
|
||||
When a workflow only needs a small subset of the repo (e.g., a single script under `.github/`), pass `sparse-checkout` to `actions/checkout` instead of cloning the whole tree. A full checkout of this repo takes around 10 seconds on average; a sparse checkout finishes in a fraction of that.
|
||||
|
||||
```yaml
|
||||
# Bad: clones the entire repo just to run one script
|
||||
- uses: actions/checkout@...
|
||||
- run: bash .github/scripts/my-script.sh
|
||||
|
||||
# Good: only fetches what the job actually reads
|
||||
- uses: actions/checkout@...
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/scripts/my-script.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
- run: bash .github/scripts/my-script.sh
|
||||
```
|
||||
|
||||
When listing directories, leave cone mode on (the default):
|
||||
|
||||
```yaml
|
||||
- uses: actions/checkout@...
|
||||
with:
|
||||
sparse-checkout: |
|
||||
.github/scripts
|
||||
dev
|
||||
```
|
||||
|
||||
Set `sparse-checkout-cone-mode: false` only when you need to target individual files or non-prefix glob patterns.
|
||||
|
||||
## `pipefail` Is Already On
|
||||
|
||||
Every workflow in this repo sets top-level `defaults.run.shell: bash` (enforced by [`.github/policy.rego`](../../.github/policy.rego)). GitHub Actions runs `shell: bash` as `bash --noprofile --norc -eo pipefail {0}`, so `pipefail` is already enabled. Don't ask for `set -o pipefail` in workflow `run:` steps. ([docs](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#defaultsrunshell))
|
||||
@@ -0,0 +1,335 @@
|
||||
---
|
||||
paths: "**/*.py"
|
||||
---
|
||||
|
||||
# Python Style Guide
|
||||
|
||||
This guide documents Python coding conventions that go beyond what [ruff](https://docs.astral.sh/ruff/) and [clint](../../dev/clint/) can enforce. The practices below require human judgment to implement correctly and improve code readability, maintainability, and testability across the MLflow codebase.
|
||||
|
||||
## Avoid Redundant Docstrings
|
||||
|
||||
Omit docstrings that merely repeat the function name or provide no additional value. Function names should be self-documenting.
|
||||
|
||||
```python
|
||||
# Bad
|
||||
def calculate_sum(a: int, b: int) -> int:
|
||||
"""Calculate sum"""
|
||||
return a + b
|
||||
|
||||
|
||||
# Good
|
||||
def calculate_sum(a: int, b: int) -> int:
|
||||
return a + b
|
||||
```
|
||||
|
||||
## Prefer `typing.Literal` for Fixed-String Parameters
|
||||
|
||||
When a parameter only accepts a fixed set of string values, use `typing.Literal` instead of a plain `str` type hint. This improves type-checking, enables IDE autocompletion, and documents allowed values at the type level.
|
||||
|
||||
```python
|
||||
# Bad
|
||||
def f(app: str) -> None:
|
||||
"""
|
||||
Args:
|
||||
app: Application type. Either "fastapi" or "flask".
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# Good
|
||||
from typing import Literal
|
||||
|
||||
|
||||
def f(app: Literal["fastapi", "flask"]) -> None:
|
||||
"""
|
||||
Args:
|
||||
app: Application type. Either "fastapi" or "flask".
|
||||
"""
|
||||
...
|
||||
```
|
||||
|
||||
## Minimize Try-Catch Block Scope
|
||||
|
||||
Wrap only the specific operations that can raise exceptions. Keep safe operations outside the try block to improve debugging and avoid masking unexpected errors.
|
||||
|
||||
```python
|
||||
# Bad
|
||||
try:
|
||||
never_fails()
|
||||
can_fail()
|
||||
except ...:
|
||||
handle_error()
|
||||
|
||||
# Good
|
||||
never_fails()
|
||||
try:
|
||||
can_fail()
|
||||
except ...:
|
||||
handle_error()
|
||||
```
|
||||
|
||||
## Use Dataclasses Instead of Complex Tuples
|
||||
|
||||
Replace tuples with 3+ elements with named dataclasses. This improves code clarity, prevents positional argument errors, and enables type checking on individual fields.
|
||||
|
||||
```python
|
||||
# Bad
|
||||
def get_user() -> tuple[str, int, str]:
|
||||
return "Alice", 30, "Engineer"
|
||||
|
||||
|
||||
# Good
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
name: str
|
||||
age: int
|
||||
occupation: str
|
||||
|
||||
|
||||
def get_user() -> User:
|
||||
return User(name="Alice", age=30, occupation="Engineer")
|
||||
```
|
||||
|
||||
## Use `pathlib` Methods Instead of `os` Module Functions
|
||||
|
||||
When you have a `pathlib.Path` object, use its built-in methods instead of `os` module functions. This is more readable, type-safe, and follows object-oriented principles.
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
|
||||
path = Path("some/file.txt")
|
||||
|
||||
# Bad
|
||||
import os
|
||||
|
||||
os.path.exists(path)
|
||||
os.remove(path)
|
||||
|
||||
# Good
|
||||
path.exists()
|
||||
path.unlink()
|
||||
```
|
||||
|
||||
## Pass `pathlib.Path` Objects Directly to `subprocess`
|
||||
|
||||
Avoid converting `pathlib.Path` objects to strings when passing them to `subprocess` functions. Modern Python (3.8+) accepts Path objects directly, making the code cleaner and more type-safe.
|
||||
|
||||
```python
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
path = Path("some/script.py")
|
||||
|
||||
# Bad
|
||||
subprocess.check_call(["foo", "bar", str(path)])
|
||||
|
||||
# Good
|
||||
subprocess.check_call(["foo", "bar", path])
|
||||
```
|
||||
|
||||
## Use next() to Find First Match Instead of Loop-and-Break
|
||||
|
||||
Use the `next()` builtin function with a generator expression to find the first item that matches a condition. This is more concise and functional than manually looping with break statements.
|
||||
|
||||
```python
|
||||
# Bad
|
||||
result = None
|
||||
for item in items:
|
||||
if item.name == "target":
|
||||
result = item
|
||||
break
|
||||
|
||||
# Good
|
||||
result = next((item for item in items if item.name == "target"), None)
|
||||
```
|
||||
|
||||
## Use Pattern Matching When Dispatching on Structure
|
||||
|
||||
Pattern matching is preferred for string splitting (replaces unsafe unpacking), nested dict access (replaces chained `.get()` calls), and list length dispatch (replaces verbose length checks).
|
||||
|
||||
### String Splitting
|
||||
|
||||
```python
|
||||
# Bad
|
||||
a, b = some_str.split(".")
|
||||
|
||||
# Good
|
||||
match some_str.split("."):
|
||||
case [a, b]:
|
||||
...
|
||||
case _:
|
||||
raise ValueError(f"Invalid format: {some_str!r}")
|
||||
```
|
||||
|
||||
### Nested Dict Key Extraction
|
||||
|
||||
```python
|
||||
# Bad
|
||||
def f(data):
|
||||
return data.get("data", {}).get("repository", {}).get("pullRequest", {}).get("nodes", [])
|
||||
|
||||
|
||||
# Good
|
||||
def f(data):
|
||||
match data:
|
||||
case {"data": {"repository": {"pullRequest": {"nodes": nodes}}}}:
|
||||
return nodes
|
||||
case _:
|
||||
return []
|
||||
```
|
||||
|
||||
### List Length Dispatch
|
||||
|
||||
```python
|
||||
# Bad
|
||||
def f(items):
|
||||
if len(items) == 0:
|
||||
raise ValueError("No results found")
|
||||
elif len(items) == 1:
|
||||
return items[0].id
|
||||
else:
|
||||
raise ValueError("Multiple results found")
|
||||
|
||||
|
||||
# Good
|
||||
def f(items):
|
||||
match items:
|
||||
case []:
|
||||
raise ValueError("No results found")
|
||||
case [item]:
|
||||
return item.id
|
||||
case _:
|
||||
raise ValueError("Multiple results found")
|
||||
```
|
||||
|
||||
## Always Verify Mock Calls with Assertions
|
||||
|
||||
Every mocked function must have an assertion (`assert_called`, `assert_called_once`, etc.) to verify it was invoked correctly. Without assertions, tests may pass even when the mocked code isn't executed.
|
||||
|
||||
```python
|
||||
from unittest import mock
|
||||
|
||||
|
||||
# Bad
|
||||
def test_foo():
|
||||
with mock.patch("foo.bar"):
|
||||
calls_bar()
|
||||
|
||||
|
||||
# Good
|
||||
def test_bar():
|
||||
with mock.patch("foo.bar") as mock_bar:
|
||||
calls_bar()
|
||||
mock_bar.assert_called_once()
|
||||
```
|
||||
|
||||
## Set Mock Behaviors in Patch Declaration
|
||||
|
||||
Define `return_value` and `side_effect` directly in the `patch()` call rather than assigning them afterward. This keeps mock configuration explicit and reduces setup code.
|
||||
|
||||
```python
|
||||
from unittest import mock
|
||||
|
||||
|
||||
# Bad
|
||||
def test_foo():
|
||||
with mock.patch("foo.bar") as mock_bar:
|
||||
mock_bar.return_value = 42
|
||||
calls_bar()
|
||||
|
||||
with mock.patch("foo.bar") as mock_bar:
|
||||
mock_bar.side_effect = Exception("Error")
|
||||
calls_bar()
|
||||
|
||||
|
||||
# Good
|
||||
def test_foo():
|
||||
with mock.patch("foo.bar", return_value=42) as mock_bar:
|
||||
calls_bar()
|
||||
|
||||
with mock.patch("foo.bar", side_effect=Exception("Error")) as mock_bar:
|
||||
calls_bar()
|
||||
```
|
||||
|
||||
## Parametrize Tests with Multiple Input Cases
|
||||
|
||||
Use `@pytest.mark.parametrize` to test multiple inputs instead of repeating assertions. This creates separate test cases for each input, making failures easier to diagnose and tests more maintainable.
|
||||
|
||||
```python
|
||||
# Bad
|
||||
def test_foo():
|
||||
assert foo("a") == 0
|
||||
assert foo("b") == 1
|
||||
assert foo("c") == 2
|
||||
|
||||
|
||||
# Good
|
||||
@pytest.mark.parametrize(
|
||||
("input", "expected"),
|
||||
[
|
||||
("a", 0),
|
||||
("b", 1),
|
||||
("c", 2),
|
||||
],
|
||||
)
|
||||
def test_foo(input: str, expected: int):
|
||||
assert foo(input) == expected
|
||||
```
|
||||
|
||||
## Avoid Custom Messages in Test Asserts
|
||||
|
||||
Pytest's assertion introspection provides detailed failure information automatically. Avoid adding custom messages to `assert` statements in tests unless absolutely necessary.
|
||||
|
||||
```python
|
||||
# Bad
|
||||
def test_list_items():
|
||||
items = list_items()
|
||||
assert len(items) == 3, f"Expected 3 items, got {len(items)}"
|
||||
|
||||
|
||||
# Good
|
||||
def test_list_items():
|
||||
items = list_items()
|
||||
assert len(items) == 3
|
||||
```
|
||||
|
||||
## Preserve function metadata and type information in decorators
|
||||
|
||||
When writing decorators, always use `@functools.wraps` to preserve function metadata (like `__name__` and `__doc__`), and use `typing.ParamSpec` and `typing.TypeVar` to preserve the function's type information for accurate type checking and autocompletion in IDEs.
|
||||
|
||||
```python
|
||||
# Bad
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
... # Pre-execution logic (e.g., logging, validation, setup)
|
||||
res = f(*args, **kwargs)
|
||||
... # Post-execution logic (e.g., cleanup, result transformation)
|
||||
return res
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
# Good
|
||||
import functools
|
||||
from typing import Callable, ParamSpec, TypeVar
|
||||
|
||||
_P = ParamSpec("P")
|
||||
_R = TypeVar("R")
|
||||
|
||||
|
||||
def decorator(f: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||
@functools.wraps(f)
|
||||
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||
... # Pre-execution logic (e.g., logging, validation, setup)
|
||||
res = f(*args, **kwargs)
|
||||
... # Post-execution logic (e.g., cleanup, result transformation)
|
||||
return res
|
||||
|
||||
return wrapper
|
||||
```
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Downloads the Claude Code binary from the official distribution URL and verifies it against the
|
||||
# checksum published in the per-version manifest, avoiding `curl | bash` which
|
||||
# pipes an unverified script with access to CI secrets.
|
||||
# Ref: https://github.com/dagster-io/erk/blob/61ecee08754717959bb2f9cb6e7079df81ba80ea/.github/actions/setup-claude-code/action.yml
|
||||
set -euo pipefail
|
||||
|
||||
if [ "${CI:-}" != "true" ]; then
|
||||
echo "Error: This script is intended for CI only." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DOWNLOAD_URL="https://downloads.claude.ai/claude-code-releases"
|
||||
PLATFORM="linux-x64"
|
||||
|
||||
VERSION="$(curl -fsSL --retry 3 --retry-delay 2 "$DOWNLOAD_URL/latest")"
|
||||
CHECKSUM="$(curl -fsSL --retry 3 --retry-delay 2 "$DOWNLOAD_URL/$VERSION/manifest.json" \
|
||||
| jq -r ".platforms[\"$PLATFORM\"].checksum")"
|
||||
|
||||
tmp_claude="$(mktemp)"
|
||||
trap 'rm -f "$tmp_claude"' EXIT
|
||||
|
||||
curl -fsSL --retry 3 --retry-delay 2 "$DOWNLOAD_URL/$VERSION/$PLATFORM/claude" -o "$tmp_claude"
|
||||
echo "${CHECKSUM} $tmp_claude" | sha256sum -c -
|
||||
mkdir -p ~/.local/bin
|
||||
chmod +x "$tmp_claude"
|
||||
mv "$tmp_claude" ~/.local/bin/claude
|
||||
trap - EXIT
|
||||
echo "Installed Claude Code $VERSION"
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
command -v jq >/dev/null || exit 0
|
||||
|
||||
input=$(cat)
|
||||
|
||||
cwd=$(echo "$input" | jq -r '.workspace.current_dir')
|
||||
dir=$(basename "$cwd")
|
||||
model=$(echo "$input" | jq -r '.model.display_name // empty')
|
||||
|
||||
cd "$cwd" 2>/dev/null || cd /
|
||||
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
|
||||
dirty=""
|
||||
if [ -n "$branch" ]; then
|
||||
# Check for staged or unstaged changes to tracked files
|
||||
git diff --quiet 2>/dev/null && git diff --cached --quiet 2>/dev/null || dirty="*"
|
||||
# Check for untracked files (if not already dirty)
|
||||
[ -z "$dirty" ] && [ -n "$(git ls-files --others --exclude-standard 2>/dev/null | head -1)" ] && dirty="*"
|
||||
fi
|
||||
|
||||
used=$(echo "$input" | jq -r '.context_window.used_percentage // empty')
|
||||
five=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty')
|
||||
|
||||
output="$dir"
|
||||
[ -n "$branch" ] && output="$output ($branch$dirty)"
|
||||
[ -n "$model" ] && output="$output | $model"
|
||||
[ -n "$used" ] && output="$output | ctx $(printf "%.0f" "$used")%"
|
||||
[ -n "$five" ] && output="$output | 5h $(printf "%.0f" "$five")%"
|
||||
|
||||
echo "$output"
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage: claude --output-format stream-json ... | .claude/scripts/stream.sh [output-file]
|
||||
|
||||
tee "${1:-/dev/null}" \
|
||||
| jq --unbuffered -r '
|
||||
if .type == "assistant" then
|
||||
.message.content[] |
|
||||
if .type == "text" then
|
||||
"🤖 \(.text)"
|
||||
elif .type == "tool_use" then
|
||||
"🔧 \(.name)\(if .input then ": \(.input | tostring | .[0:200])" else "" end)"
|
||||
elif .type == "thinking" then
|
||||
"🧠 thinking (\(.thinking | length) chars)"
|
||||
else
|
||||
empty
|
||||
end
|
||||
elif .type == "user" then
|
||||
.message.content[]?
|
||||
| select(.type == "tool_result")
|
||||
| "📥 tool_result (\(.content | tostring | length) chars)\(if .is_error then " ❌" else "" end)"
|
||||
elif .type == "system" and .subtype == "init" then
|
||||
"🚀 init: \(.model) (v\(.claude_code_version), session \(.session_id[0:8]))"
|
||||
elif .type == "result" then
|
||||
"✅ Done (\((.duration_ms / 100 | round) / 10)s, \(.num_turns) turns, \(.usage.input_tokens + .usage.output_tokens) tokens, $\(.total_cost_usd * 100 | round / 100))"
|
||||
else
|
||||
empty
|
||||
end'
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/scripts/statusline.sh"
|
||||
},
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-uv.sh",
|
||||
"timeout": 5.0
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "uv run --directory=$CLAUDE_PROJECT_DIR --no-project .claude/hooks/validate_pr_body.py",
|
||||
"timeout": 5.0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Skills CLI
|
||||
|
||||
A Python package that provides CLI commands for Claude Code skills.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
uv run --package skills skills <command> [args]
|
||||
```
|
||||
|
||||
Run `uv run --package skills skills --help` to see available commands.
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
name: add-review-comment
|
||||
description: Add a review comment to a GitHub pull request.
|
||||
allowed-tools:
|
||||
- Bash(gh api:*)
|
||||
- Bash(gh pr view:*)
|
||||
- Bash(uv run --package skills skills fetch-diff:*)
|
||||
---
|
||||
|
||||
# Add Review Comment
|
||||
|
||||
Adds a review comment to a specific line in a GitHub pull request.
|
||||
|
||||
## Step 1: Locate the line to comment on
|
||||
|
||||
Use the `fetch-diff` skill (optionally piped through `grep`) to locate the line to comment on.
|
||||
|
||||
## Step 2: Post the comment
|
||||
|
||||
**Single-line comment:**
|
||||
|
||||
```bash
|
||||
gh api repos/<owner>/<repo>/pulls/<pr_number>/comments \
|
||||
# Body must end with "🤖 Generated with Claude" on a separate line
|
||||
-f body=<comment> \
|
||||
-f path=<file_path> \
|
||||
-F line=<line_number> \
|
||||
-f side=<side> \
|
||||
-f commit_id="$(gh pr view <pr_number> --repo <owner>/<repo> --json headRefOid -q .headRefOid)" \
|
||||
--jq '.html_url'
|
||||
```
|
||||
|
||||
**Multi-line comment:**
|
||||
|
||||
```bash
|
||||
gh api repos/<owner>/<repo>/pulls/<pr_number>/comments \
|
||||
# Body must end with "🤖 Generated with Claude" on a separate line
|
||||
-f body=<comment> \
|
||||
-f path=<file_path> \
|
||||
-F start_line=<first_line> \
|
||||
-f start_side=<side> \
|
||||
-F line=<last_line> \
|
||||
-f side=<side> \
|
||||
-f commit_id="$(gh pr view <pr_number> --repo <owner>/<repo> --json headRefOid -q .headRefOid)" \
|
||||
--jq '.html_url'
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
- `line`: Line number in the file (for multi-line, the last line)
|
||||
- `side`: `RIGHT` for added/modified lines (+), `LEFT` for deleted lines (-)
|
||||
- `start_line`/`start_side`: For multi-line comments, the first line of the range
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use suggestion blocks (three backticks + "suggestion") for simple fixes that maintainers can apply with one click
|
||||
|
||||
````
|
||||
```suggestion
|
||||
<suggested code here>
|
||||
```
|
||||
````
|
||||
|
||||
Note: Preserve original indentation exactly in suggestion blocks
|
||||
|
||||
- For repetitive issues, leave one representative comment instead of flagging every instance
|
||||
- For bugs, explain the potential problem and suggested fix clearly
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
name: analyze-ci
|
||||
description: Analyze failed GitHub Action jobs for a pull request.
|
||||
argument-hint: "url(s) of failed GitHub Action jobs, workflow runs, or PRs"
|
||||
allowed-tools:
|
||||
- Bash(uv run --package skills skills fetch-logs:*)
|
||||
- Read
|
||||
---
|
||||
|
||||
# Analyze CI Failures
|
||||
|
||||
Fetch logs from failed GitHub Action jobs and produce a focused per-job failure summary.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **GitHub Token**: Auto-detected via `gh auth token`, or set `GH_TOKEN`.
|
||||
- Single-quote URLs when invoking to keep the shell from interpreting `?` and other special characters in the URL.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Fetch logs.** Run:
|
||||
|
||||
```bash
|
||||
uv run --package skills skills fetch-logs $ARGUMENTS
|
||||
```
|
||||
|
||||
The command prints one block per failed job containing the workflow/job name, URL, failed step, and paths to the cached raw log, failed-step log, and (optional) package versions file.
|
||||
|
||||
2. **Read each failed-step log and summarize it.** For every block, Read the file at its `Failed step log:` path, then identify:
|
||||
|
||||
- The root cause.
|
||||
- Specific error messages (assertion errors, exceptions, stack traces).
|
||||
- Full pytest test names where applicable (e.g. `tests/test_foo.py::test_bar`).
|
||||
- A short log snippet showing the error context.
|
||||
|
||||
3. **Format each summary** with these fields, then a blank line, then the 1-2 paragraph summary.
|
||||
|
||||
- `Failed job: <workflow name> / <job name>`
|
||||
- `Failed step: <step name>`
|
||||
- `URL: <job_url>`
|
||||
- `Raw log: <raw_log_path>`
|
||||
- `Failed step log: <failed_step_log_path>`
|
||||
- `Package versions: <package_versions_path>` (if present)
|
||||
|
||||
Preserve the `Raw log:`, `Failed step log:`, and `Package versions:` paths verbatim from step 1 so downstream agents can grep deeper.
|
||||
|
||||
## Invocation examples
|
||||
|
||||
```bash
|
||||
# All failed jobs on a PR
|
||||
/analyze-ci https://github.com/mlflow/mlflow/pull/19601
|
||||
|
||||
# All failed jobs in one workflow run
|
||||
/analyze-ci https://github.com/mlflow/mlflow/actions/runs/22626454465
|
||||
|
||||
# Specific job by URL
|
||||
/analyze-ci https://github.com/mlflow/mlflow/actions/runs/12345/job/67890
|
||||
|
||||
# Multiple URLs at once
|
||||
/analyze-ci https://github.com/mlflow/mlflow/actions/runs/123/job/456 https://github.com/mlflow/mlflow/actions/runs/789/job/012
|
||||
```
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
name: copilot
|
||||
description: Hand off a task to GitHub Copilot.
|
||||
allowed-tools:
|
||||
- Bash(gh agent-task create:*)
|
||||
- Bash(gh agent-task list:*)
|
||||
- Bash(gh agent-task view:*)
|
||||
- Bash(bash .claude/skills/copilot/poll.sh *)
|
||||
- Bash(bash .claude/skills/copilot/approve.sh *)
|
||||
- Bash(gh api:*)
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Create a task with an inline description
|
||||
gh agent-task create "<task description>"
|
||||
|
||||
# Create a task from a markdown file
|
||||
gh agent-task create -F task-desc.md
|
||||
```
|
||||
|
||||
`gh agent-task create` may print a `queued` message instead of a session URL (e.g., `job <job-id> queued. View progress: https://github.com/copilot/agents`). This means the task was created successfully but may stay queued for minutes or longer. Wait and then run `gh agent-task list` to check if a session has started.
|
||||
|
||||
## Post-creation
|
||||
|
||||
Print both the session URL and the PR URL (strip `/agent-sessions/...` from the session URL).
|
||||
|
||||
Example:
|
||||
|
||||
- Session: https://github.com/mlflow/mlflow/pull/20905/agent-sessions/abc123
|
||||
- PR: https://github.com/mlflow/mlflow/pull/20905
|
||||
|
||||
## Polling for completion
|
||||
|
||||
Once Copilot starts working, poll in the background until Copilot finishes. The script automatically finds the latest session for the PR:
|
||||
|
||||
```bash
|
||||
bash .claude/skills/copilot/poll.sh "<owner>/<repo>" <pr_number>
|
||||
```
|
||||
|
||||
## Sending feedback
|
||||
|
||||
If the PR needs changes, batch all feedback into a single review with `@copilot` in each comment so they're addressed in one session:
|
||||
|
||||
```bash
|
||||
gh api repos/<owner>/<repo>/pulls/<pr_number>/reviews --input - <<'EOF'
|
||||
{
|
||||
"event": "COMMENT",
|
||||
"comments": [
|
||||
{
|
||||
"path": "<file_path>",
|
||||
"line": <line_number>,
|
||||
"side": "RIGHT",
|
||||
"body": "@copilot <comment>",
|
||||
// ... more params
|
||||
},
|
||||
// ... more comments
|
||||
]
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
After sending feedback, Copilot starts a new session, typically within ~10 seconds. Wait at least 15 seconds before polling so the new session gets picked up.
|
||||
|
||||
## Approving workflows
|
||||
|
||||
Copilot commits require approval to trigger workflows for security reasons, while maintainer commits do not. Once the PR is finalized, run the approve script:
|
||||
|
||||
```bash
|
||||
bash .claude/skills/copilot/approve.sh "<owner>/<repo>" <pr_number>
|
||||
```
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Rerun action_required workflow runs for Copilot PRs.
|
||||
# The /approve API fails with 'not a fork pull request'. Use /rerun instead.
|
||||
set -euo pipefail
|
||||
|
||||
repo="$1"
|
||||
pr_number="$2"
|
||||
|
||||
head_sha=$(gh pr view "$pr_number" --repo "$repo" --json headRefOid --jq '.headRefOid')
|
||||
|
||||
run_ids=$(
|
||||
gh api --paginate "repos/${repo}/actions/runs?head_sha=${head_sha}" \
|
||||
--jq '
|
||||
.workflow_runs[]
|
||||
| select(.conclusion == "action_required" and .actor.login == "Copilot")
|
||||
| .id
|
||||
'
|
||||
)
|
||||
|
||||
if [[ -z "$run_ids" ]]; then
|
||||
echo "No action_required workflow runs found"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Rerunning action_required workflows..."
|
||||
pids=()
|
||||
while IFS= read -r run_id; do
|
||||
(
|
||||
if gh api --method POST "repos/${repo}/actions/runs/${run_id}/rerun" >/dev/null 2>&1; then
|
||||
echo " Rerun triggered for run $run_id"
|
||||
else
|
||||
echo " Failed to rerun run $run_id"
|
||||
fi
|
||||
) &
|
||||
pids+=($!)
|
||||
done <<< "$run_ids"
|
||||
wait "${pids[@]}"
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo="$1" # owner/repo format
|
||||
pr_number="$2"
|
||||
max_seconds=1800 # 30 minutes
|
||||
|
||||
# Find the latest session for this PR
|
||||
session_id=$(
|
||||
gh agent-task list \
|
||||
--json id,pullRequestNumber,createdAt,repository \
|
||||
--jq "
|
||||
[.[] | select(.repository == \"${repo}\" and .pullRequestNumber == ${pr_number})]
|
||||
| sort_by(.createdAt)
|
||||
| last
|
||||
| .id
|
||||
"
|
||||
)
|
||||
echo "Polling session $session_id for PR #${pr_number}"
|
||||
|
||||
while true; do
|
||||
if (( SECONDS > max_seconds )); then
|
||||
echo "Timed out after ${max_seconds}s waiting for Copilot to finish"
|
||||
exit 1
|
||||
fi
|
||||
state=$(gh agent-task view "$session_id" --json state --jq '.state')
|
||||
echo "State: $state (elapsed ${SECONDS}s)"
|
||||
if [[ "$state" != "queued" && "$state" != "in_progress" ]]; then
|
||||
echo "Copilot finished with state: $state"
|
||||
break
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
|
||||
# Mark PR ready for review if still in draft
|
||||
is_draft=$(gh pr view "$pr_number" --repo "$repo" --json isDraft --jq '.isDraft')
|
||||
transitioned_to_ready=false
|
||||
if [[ "$is_draft" == "true" ]]; then
|
||||
gh pr ready "$pr_number" --repo "$repo"
|
||||
echo "Marked PR #${pr_number} as ready for review"
|
||||
transitioned_to_ready=true
|
||||
fi
|
||||
|
||||
# Poll for Copilot review completion only if we just transitioned from draft to ready
|
||||
if [[ "$transitioned_to_ready" == "true" ]]; then
|
||||
review_max_seconds=600 # 10 minutes
|
||||
review_start=$SECONDS
|
||||
echo "Waiting for Copilot review..."
|
||||
while true; do
|
||||
if (( SECONDS - review_start > review_max_seconds )); then
|
||||
echo "Warning: Timed out after ${review_max_seconds}s waiting for Copilot review"
|
||||
break
|
||||
fi
|
||||
review_info=$(
|
||||
gh api "repos/${repo}/pulls/${pr_number}/reviews" \
|
||||
--jq '
|
||||
[.[] | select(.user.login == "copilot-pull-request-reviewer[bot]")]
|
||||
| last
|
||||
| {state: (.state // empty), id: (.id // empty)}
|
||||
' 2>/dev/null \
|
||||
|| {
|
||||
echo "Warning: Failed to fetch Copilot review state; treating as no review yet" >&2
|
||||
echo '{}'
|
||||
}
|
||||
)
|
||||
review_state=$(echo "$review_info" | jq -r '.state // empty')
|
||||
review_id=$(echo "$review_info" | jq -r '.id // empty')
|
||||
if [[ -n "$review_state" ]]; then
|
||||
comment_count=$(
|
||||
gh api "repos/${repo}/pulls/${pr_number}/reviews/${review_id}/comments" \
|
||||
--jq 'length' 2>/dev/null || echo "0"
|
||||
)
|
||||
echo "Copilot review: $review_state ($comment_count comment(s), elapsed $((SECONDS - review_start))s)"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for Copilot review... (elapsed $((SECONDS - review_start))s)"
|
||||
sleep 30
|
||||
done
|
||||
fi
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: fetch-diff
|
||||
description: Fetch PR diff with filtering and line numbers for code review.
|
||||
allowed-tools:
|
||||
- Bash(uv run --package skills skills fetch-diff:*)
|
||||
---
|
||||
|
||||
# Fetch PR Diff
|
||||
|
||||
Fetches a pull request diff and adds line numbers for easier review comment placement. Auto-generated files are shown with masked diffs.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
uv run --package skills skills fetch-diff <pr_url> [--files <pattern> ...]
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Fetch the full diff
|
||||
uv run --package skills skills fetch-diff https://github.com/mlflow/mlflow/pull/123
|
||||
|
||||
# Fetch only Python files
|
||||
uv run --package skills skills fetch-diff https://github.com/mlflow/mlflow/pull/123 --files '*.py'
|
||||
|
||||
# Fetch only frontend files
|
||||
uv run --package skills skills fetch-diff https://github.com/mlflow/mlflow/pull/123 --files 'mlflow/server/js/*'
|
||||
|
||||
# Multiple patterns
|
||||
uv run --package skills skills fetch-diff https://github.com/mlflow/mlflow/pull/123 --files '*.py' '*.ts'
|
||||
```
|
||||
|
||||
Token is auto-detected from `GH_TOKEN` env var or `gh auth token`.
|
||||
|
||||
## Output Example
|
||||
|
||||
**Regular file:**
|
||||
|
||||
```
|
||||
diff --git a/path/to/file.py b/path/to/file.py
|
||||
index abc123..def456 100644
|
||||
--- a/path/to/file.py
|
||||
+++ b/path/to/file.py
|
||||
@@ -10,7 +10,7 @@
|
||||
10 10 | import os
|
||||
11 11 | import sys
|
||||
12 12 | from typing import Optional
|
||||
13 | -from old_module import OldClass
|
||||
14 | +from new_module import NewClass
|
||||
14 15 |
|
||||
15 16 | def process_data(input_file: str) -> dict:
|
||||
```
|
||||
|
||||
**Auto-generated file (masked):**
|
||||
|
||||
```
|
||||
diff --git a/uv.lock b/uv.lock
|
||||
index abc123..def456 100644
|
||||
--- a/uv.lock
|
||||
+++ b/uv.lock
|
||||
[Auto-generated file - diff masked]
|
||||
```
|
||||
|
||||
**Deleted file (masked):**
|
||||
|
||||
```
|
||||
diff --git a/path/to/removed.py b/dev/null
|
||||
index abc123..0000000 100644
|
||||
--- a/path/to/removed.py
|
||||
+++ /dev/null
|
||||
[Deleted file - diff masked]
|
||||
```
|
||||
|
||||
Each line is annotated as `old_line new_line | <marker> content`:
|
||||
|
||||
- `-` marker (left number only) -> deleted line, `side=LEFT`, `line=old_line`
|
||||
- `+` marker (right number only) -> added line, `side=RIGHT`, `line=new_line`
|
||||
- No marker (both numbers) -> unchanged line, `side=RIGHT`, `line=new_line`
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
name: pr-review
|
||||
description: Review a GitHub pull request and emit a validated local review payload (comments + approval decision)
|
||||
disable-model-invocation: true
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Skill
|
||||
- Bash
|
||||
- Grep
|
||||
- Glob
|
||||
- Edit(//tmp/review-payload.json)
|
||||
argument-hint: "<owner_repo> <pr_number> [extra_context]"
|
||||
arguments: [owner_repo, pr_number, extra_context]
|
||||
---
|
||||
|
||||
# Review Pull Request
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/pr-review <owner_repo> <pr_number> [extra_context]
|
||||
```
|
||||
|
||||
## Arguments
|
||||
|
||||
- `<owner_repo>` (required): repository slug, e.g. `mlflow/mlflow`
|
||||
- `<pr_number>` (required): pull request number
|
||||
- `[extra_context]` (optional): additional filtering or focus instructions (e.g., a specific concern or file type)
|
||||
|
||||
## Inputs
|
||||
|
||||
This invocation is reviewing:
|
||||
|
||||
- Owner/Repo: `$owner_repo`
|
||||
- PR number: `$pr_number`
|
||||
- Extra context: `$extra_context`
|
||||
|
||||
The `<owner>`/`<repo>`/`<pr_number>` placeholders in the steps below refer to the values above (split `$owner_repo` on `/` for `<owner>` and `<repo>`).
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Gather context (run in parallel)
|
||||
|
||||
These reads are independent. Issue them as parallel tool calls in a single turn, not sequentially.
|
||||
|
||||
#### PR title and description
|
||||
|
||||
```bash
|
||||
gh pr view <pr_number> --repo "<owner>/<repo>" --json title,body
|
||||
```
|
||||
|
||||
#### PR diff hunks
|
||||
|
||||
Invoke the [`fetch-diff`](../fetch-diff/SKILL.md) skill.
|
||||
|
||||
#### Existing review threads
|
||||
|
||||
Up to 100 threads (open, resolved, and outdated) with up to 20 comments each, so you can avoid duplicating prior feedback:
|
||||
|
||||
```bash
|
||||
gh api graphql -F owner=<owner> -F repo=<repo> -F pr=<pr_number> \
|
||||
--jq '.data.repository.pullRequest.reviewThreads.nodes | map(.comments = .comments.nodes)' \
|
||||
-f query='
|
||||
query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $pr) {
|
||||
reviewThreads(first: 100) {
|
||||
nodes {
|
||||
isResolved
|
||||
isOutdated
|
||||
path
|
||||
line
|
||||
comments(first: 20) {
|
||||
nodes { author { login } body }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### 2. Load repository style rules
|
||||
|
||||
Load the repository style rules applicable to the changed files:
|
||||
|
||||
```bash
|
||||
git diff --name-only HEAD^1 | uv run --package skills skills load-rules
|
||||
```
|
||||
|
||||
### 3. In-Depth Analysis
|
||||
|
||||
The working tree holds the PR merged into the base (`refs/pull/<pr>/merge`), so file contents reflect the post-merge state. Explore it for context beyond the diff (existing patterns, call sites of changed symbols, file conventions).
|
||||
|
||||
The merge ref's base parent is also reachable as `HEAD^1`. When the diff doesn't show enough (verifying a refactor preserved behavior, reading the full content of a deleted file, or seeing the pre-change version of a heavily modified file), use `git show HEAD^1:<path>` rather than re-fetching via the GitHub API.
|
||||
|
||||
#### Don't comment on
|
||||
|
||||
- Pre-existing code. You may read unchanged/context lines to understand the change, but only file findings against the changed lines (added, modified, or deleted), even if surrounding code looks suboptimal.
|
||||
- Issues already caught by formatters or linters (unused imports, formatting, line length, simple typos, etc.).
|
||||
|
||||
Evaluate the changed code across these dimensions:
|
||||
|
||||
- **Correctness**: logic errors, off-by-one, incorrect API usage, broken invariants, regressions in behavior
|
||||
- **Security**: injection, unsafe deserialization, secret leakage, missing authz/authn, unsafe defaults
|
||||
- **Edge cases**: None/empty/zero inputs, concurrency, error paths, retries, large/unicode inputs
|
||||
- **Efficiency**: needless N+1 queries, redundant work in hot paths, allocations in tight loops
|
||||
- **Readability & maintainability**: unclear names, dead code, premature abstractions, comments that restate the code
|
||||
- **Test coverage**: new behavior lacks tests, tests assert on the wrong thing, mocks hide real failures
|
||||
- **Style guide**: violations of the rules loaded in step 2
|
||||
|
||||
### 4. Decision Point
|
||||
|
||||
Classify each finding by severity (matches `.github/instructions/code-review.instructions.md`):
|
||||
|
||||
| Severity | Emoji | Use for |
|
||||
| -------- | ----- | -------------------------------------------------------------------------------- |
|
||||
| CRITICAL | 🔴 | bugs, logic errors, security issues, data loss risk, broken public API |
|
||||
| MODERATE | 🟡 | non-blocking quality concerns where the code works but could be clearer or safer |
|
||||
| NIT | 🟢 | pure style/preference the author can ignore |
|
||||
|
||||
Determine the review `event`:
|
||||
|
||||
- **No CRITICAL findings** -> `event: "APPROVE"`
|
||||
- **Any CRITICAL finding** -> `event: "COMMENT"`
|
||||
|
||||
### 5. Emit Local Review Payload
|
||||
|
||||
Read [`review-payload.schema.json`](./review-payload.schema.json), then write `/tmp/review-payload.json` matching it and validate.
|
||||
|
||||
Authoring rules not captured by the schema:
|
||||
|
||||
- One comment per distinct finding, anchored to the most relevant changed line. For repeated identical issues, leave a single representative comment rather than flagging every instance.
|
||||
- Anchors must land in a diff hunk. For findings about out-of-diff code, anchor to any changed line (prefer the same file when it has hunks) and name the actual `path:line` in the body.
|
||||
- Keep comments constructive and specific: state the problem, why it matters, and a concrete suggestion when possible.
|
||||
- Use suggestion blocks for simple fixes — fence with ` ```suggestion ` and preserve original indentation.
|
||||
- If you have no findings, emit an empty `comments` array.
|
||||
|
||||
Validate before finishing — fix any errors and re-emit until this passes:
|
||||
|
||||
```bash
|
||||
uv run --package skills skills validate-review /tmp/review-payload.json
|
||||
```
|
||||
|
||||
Do not post the review or comments by running `gh pr review`, calling GitHub review/comment APIs, or using any other skills. Stop after writing and validating the local review payload.
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "PR Review Payload",
|
||||
"description": "Payload emitted by the pr-review skill and posted via POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["event", "body", "comments"],
|
||||
"properties": {
|
||||
"event": {
|
||||
"description": "Review action. APPROVE only when there are no CRITICAL findings; otherwise COMMENT.",
|
||||
"type": "string",
|
||||
"enum": ["APPROVE", "COMMENT"]
|
||||
},
|
||||
"body": {
|
||||
"description": "Concise top-level review summary in 2-3 sentences capturing the overall assessment. Do not restate individual findings (those belong in inline comments). Must end with the '🤖 Generated with Claude' footer on its own line.",
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "(^|\\n)🤖 Generated with Claude\\s*$"
|
||||
},
|
||||
"commit_id": {
|
||||
"description": "Head commit SHA being reviewed. Optional; GitHub defaults to the latest commit when omitted.",
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{40}$"
|
||||
},
|
||||
"comments": {
|
||||
"description": "Inline review comments. Empty array is allowed (e.g., clean APPROVE).",
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/$defs/comment" }
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": { "event": { "const": "APPROVE" } },
|
||||
"required": ["event"]
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"comments": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"body": {
|
||||
"not": { "$ref": "#/$defs/criticalPrefix" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"side": {
|
||||
"type": "string",
|
||||
"enum": ["LEFT", "RIGHT"]
|
||||
},
|
||||
"criticalPrefix": {
|
||||
"type": "string",
|
||||
"pattern": "^🔴 \\*\\*CRITICAL:\\*\\* "
|
||||
},
|
||||
"moderatePrefix": {
|
||||
"type": "string",
|
||||
"pattern": "^🟡 \\*\\*MODERATE:\\*\\* "
|
||||
},
|
||||
"nitPrefix": {
|
||||
"type": "string",
|
||||
"pattern": "^🟢 \\*\\*NIT:\\*\\* "
|
||||
},
|
||||
"comment": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["path", "body", "line", "side"],
|
||||
"properties": {
|
||||
"path": {
|
||||
"description": "File path relative to the repo root. Must be a file with at least one hunk in the PR diff.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"body": {
|
||||
"description": "Comment body. Must start with '🔴 **CRITICAL:** ', '🟡 **MODERATE:** ', or '🟢 **NIT:** '.",
|
||||
"anyOf": [
|
||||
{ "$ref": "#/$defs/criticalPrefix" },
|
||||
{ "$ref": "#/$defs/moderatePrefix" },
|
||||
{ "$ref": "#/$defs/nitPrefix" }
|
||||
]
|
||||
},
|
||||
"line": {
|
||||
"description": "Anchor line. Must be inside a diff hunk on the chosen side (added/context on RIGHT, deleted/context on LEFT). For multi-line comments, this is the last line of the range.",
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"side": {
|
||||
"description": "RIGHT for the post-merge file (added/context lines), LEFT for the pre-change file (deleted/context lines). RIGHT is the common case.",
|
||||
"$ref": "#/$defs/side"
|
||||
},
|
||||
"start_line": {
|
||||
"description": "First line of a multi-line range. Same hunk requirement as `line`, and must be < line. Must be set together with start_side.",
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"start_side": {
|
||||
"description": "Side for start_line. Must be set together with start_line.",
|
||||
"$ref": "#/$defs/side"
|
||||
}
|
||||
},
|
||||
"dependentRequired": {
|
||||
"start_line": ["start_side"],
|
||||
"start_side": ["start_line"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[project]
|
||||
name = "skills"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
"jsonschema",
|
||||
"pydantic",
|
||||
"pyyaml",
|
||||
"typing_extensions",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
skills = "skills.cli:main"
|
||||
@@ -0,0 +1,21 @@
|
||||
import argparse
|
||||
|
||||
from skills.commands import (
|
||||
fetch_diff,
|
||||
fetch_logs,
|
||||
load_rules,
|
||||
validate_review,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(prog="skills")
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
fetch_diff.register(subparsers)
|
||||
fetch_logs.register(subparsers)
|
||||
load_rules.register(subparsers)
|
||||
validate_review.register(subparsers)
|
||||
|
||||
args = parser.parse_args()
|
||||
args.func(args)
|
||||
@@ -0,0 +1,183 @@
|
||||
# ruff: noqa: T201
|
||||
"""Fetch PR diff with filtering and line numbers for code review."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import fnmatch
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from skills.github import GitHubClient, parse_pr_url
|
||||
|
||||
_MASKED_DIFF_MESSAGE = "[Auto-generated file - diff masked]"
|
||||
_DELETED_DIFF_MESSAGE = "[Deleted file - diff masked]"
|
||||
|
||||
|
||||
def extract_stacked_pr_base_sha(pr_body: str | None, head_ref: str) -> str | None:
|
||||
"""Extract the base SHA from the stacked PR incremental diff link.
|
||||
|
||||
In stacked PR descriptions, the current PR is marked with bold (double
|
||||
asterisks). Example stack tree::
|
||||
|
||||
## Stacked PR
|
||||
- [branch_a](url) [Files changed](url)
|
||||
- [**branch_b**](url) [Files changed](url/files/abc123..def456)
|
||||
- [branch_c](url) [Files changed](url/files/def456..789ghi)
|
||||
|
||||
We find the bold entry matching the branch name and extract the base SHA
|
||||
from ``/files/<base>..<head>``.
|
||||
""" # clint: disable=markdown-link
|
||||
if not pr_body or "Stacked PR" not in pr_body:
|
||||
return None
|
||||
|
||||
# Find the line with bold branch name [**<branch>**], then extract /files/<base>..<head>
|
||||
marker = f"[**{head_ref}**]"
|
||||
for line in pr_body.split("\n"):
|
||||
if marker in line:
|
||||
if m := re.search(r"/files/(?P<base>[a-f0-9]{7,40})\.\.(?P<head>[a-f0-9]{7,40})", line):
|
||||
return m.group("base")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def is_autogenerated_file(file_path: str) -> bool:
|
||||
"""Return True if the file is auto-generated (protobuf, lock files, generated Java)."""
|
||||
path = Path(file_path)
|
||||
|
||||
if path.suffix in {".py", ".pyi"} and path.is_relative_to("mlflow/protos"):
|
||||
return True
|
||||
|
||||
if path.name in {"uv.lock", "yarn.lock", "package-lock.json"}:
|
||||
return True
|
||||
|
||||
if path.suffix == ".java" and path.exists():
|
||||
with path.open() as f:
|
||||
first_line = f.readline()
|
||||
if "Generated by the protocol buffer compiler" in first_line:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def filter_diff(full_diff: str, file_patterns: list[str] | None = None) -> str:
|
||||
"""Filter diff, mask auto-generated files, and add line numbers."""
|
||||
lines = full_diff.split("\n")
|
||||
filtered_diff: list[str] = []
|
||||
in_included_file = False
|
||||
is_masked = False
|
||||
is_deleted = False
|
||||
|
||||
for line in lines:
|
||||
if line.startswith("diff --git"):
|
||||
if match := re.match(r"diff --git a/(.*?) b/(.*?)$", line):
|
||||
file_path = match.group(2)
|
||||
|
||||
if file_patterns and not any(
|
||||
fnmatch.fnmatch(file_path, pat) for pat in file_patterns
|
||||
):
|
||||
in_included_file = False
|
||||
is_masked = False
|
||||
is_deleted = False
|
||||
else:
|
||||
in_included_file = True
|
||||
is_deleted = False
|
||||
is_masked = is_autogenerated_file(file_path)
|
||||
else:
|
||||
in_included_file = False
|
||||
is_masked = False
|
||||
is_deleted = False
|
||||
|
||||
if in_included_file:
|
||||
filtered_diff.append(line)
|
||||
elif in_included_file:
|
||||
if line.startswith("deleted file mode"):
|
||||
is_deleted = True
|
||||
is_masked = True
|
||||
filtered_diff.append(line)
|
||||
elif is_masked:
|
||||
mask_message = _DELETED_DIFF_MESSAGE if is_deleted else _MASKED_DIFF_MESSAGE
|
||||
if line.startswith("@@"):
|
||||
# Only emit the mask message once (for the first hunk)
|
||||
if not filtered_diff or filtered_diff[-1] != mask_message:
|
||||
filtered_diff.append(mask_message)
|
||||
elif line.startswith(("--- ", "+++ ")):
|
||||
# Preserve diff file headers for masked files.
|
||||
filtered_diff.append(line)
|
||||
elif not line.startswith(("+", "-", " ", "\\")):
|
||||
# Other metadata lines (index, mode changes, rename info) pass through.
|
||||
filtered_diff.append(line)
|
||||
# Skip hunk content lines
|
||||
else:
|
||||
filtered_diff.append(line)
|
||||
|
||||
# Add line numbers
|
||||
result_lines: list[str] = []
|
||||
old_line = 0
|
||||
new_line = 0
|
||||
in_header = False
|
||||
|
||||
for line in filtered_diff:
|
||||
if line.startswith("diff --git"):
|
||||
in_header = True
|
||||
if result_lines:
|
||||
result_lines.append("")
|
||||
result_lines.append(line)
|
||||
elif line.startswith("@@"):
|
||||
in_header = False
|
||||
if match := re.match(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@", line):
|
||||
old_line = int(match.group(1))
|
||||
new_line = int(match.group(2))
|
||||
result_lines.append(line)
|
||||
elif in_header or line in (_MASKED_DIFF_MESSAGE, _DELETED_DIFF_MESSAGE):
|
||||
result_lines.append(line)
|
||||
elif line.startswith("-"):
|
||||
result_lines.append(f"{old_line:5d} | {line}")
|
||||
old_line += 1
|
||||
elif line.startswith("+"):
|
||||
result_lines.append(f" {new_line:5d} | {line}")
|
||||
new_line += 1
|
||||
else:
|
||||
result_lines.append(f"{old_line:5d} {new_line:5d} | {line}")
|
||||
old_line += 1
|
||||
new_line += 1
|
||||
|
||||
return "\n".join(result_lines)
|
||||
|
||||
|
||||
async def fetch_diff(pr_url: str, file_patterns: list[str] | None = None) -> str:
|
||||
owner, repo, pr_number = parse_pr_url(pr_url)
|
||||
|
||||
async with GitHubClient() as client:
|
||||
pr = await client.get_pr(owner, repo, pr_number)
|
||||
head_sha = pr.head.sha
|
||||
head_ref = pr.head.ref
|
||||
|
||||
if base_sha := extract_stacked_pr_base_sha(pr.body, head_ref):
|
||||
print(
|
||||
f"Detected stacked PR, fetching incremental diff: {base_sha[:7]}..{head_sha[:7]}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
diff = await client.get_compare_diff(owner, repo, base_sha, head_sha)
|
||||
else:
|
||||
diff = await client.get_pr_diff(owner, repo, pr_number)
|
||||
|
||||
return filter_diff(diff, file_patterns)
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
||||
parser = subparsers.add_parser("fetch-diff", help="Fetch PR diff with line numbers")
|
||||
parser.add_argument("pr_url", help="GitHub PR URL")
|
||||
parser.add_argument(
|
||||
"--files",
|
||||
nargs="+",
|
||||
help="Glob patterns to filter files (e.g. '*.py' 'mlflow/server/*')",
|
||||
)
|
||||
parser.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
result = asyncio.run(fetch_diff(args.pr_url, args.files))
|
||||
print(result)
|
||||
@@ -0,0 +1,310 @@
|
||||
# ruff: noqa: T201
|
||||
"""Fetch logs from failed GitHub Action jobs for downstream analysis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Iterable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from skills.github import GitHubClient, Job, JobStep, get_github_token
|
||||
|
||||
LOG_CACHE_DIR = Path(tempfile.gettempdir()) / "fetch-logs"
|
||||
LOG_CACHE_TTL_SECONDS = 3 * 86400
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobLogs:
|
||||
workflow_name: str
|
||||
job_name: str
|
||||
job_url: str
|
||||
failed_step: str | None
|
||||
raw_log_path: Path
|
||||
failed_step_log_path: Path | None
|
||||
package_versions_path: Path | None
|
||||
conclusion: str | None
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
print(msg, file=sys.stderr)
|
||||
|
||||
|
||||
TIMESTAMP_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z ?")
|
||||
|
||||
|
||||
def to_seconds(ts: str) -> str:
|
||||
"""Truncate timestamp to seconds precision for comparison."""
|
||||
return ts[:19] # "2026-01-05T07:17:56.1234567Z" -> "2026-01-05T07:17:56"
|
||||
|
||||
|
||||
def prune_old_cached_logs() -> None:
|
||||
"""Delete cached raw logs older than LOG_CACHE_TTL_SECONDS and empty run dirs."""
|
||||
if not LOG_CACHE_DIR.exists():
|
||||
return
|
||||
cutoff = time.time() - LOG_CACHE_TTL_SECONDS
|
||||
for cached_file in LOG_CACHE_DIR.rglob("*"):
|
||||
if cached_file.is_file() and cached_file.stat().st_mtime < cutoff:
|
||||
cached_file.unlink()
|
||||
for run_dir in LOG_CACHE_DIR.iterdir():
|
||||
if run_dir.is_dir() and not any(run_dir.iterdir()):
|
||||
run_dir.rmdir()
|
||||
|
||||
|
||||
async def download_raw_log(client: GitHubClient, job: Job) -> Path:
|
||||
"""Download the full raw log to the cache dir, or return cached path if present."""
|
||||
log_path = LOG_CACHE_DIR / str(job.run_id) / f"{job.id}.log"
|
||||
if log_path.exists():
|
||||
log(f"Using cached raw log at {log_path}")
|
||||
return log_path
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Write to a temp file and rename on success so an interrupted download
|
||||
# doesn't leave a partial file that future runs would silently reuse.
|
||||
tmp_path = log_path.with_suffix(".log.tmp")
|
||||
async with await client.get_raw(f"{job.url}/logs") as response:
|
||||
response.raise_for_status()
|
||||
with tmp_path.open("wb") as f:
|
||||
async for chunk in response.content.iter_chunked(64 * 1024):
|
||||
f.write(chunk)
|
||||
tmp_path.rename(log_path)
|
||||
log(f"Saved raw log to {log_path}")
|
||||
return log_path
|
||||
|
||||
|
||||
def iter_step_lines(log_path: Path, failed_step: JobStep) -> Iterator[str]:
|
||||
"""Yield lines from the saved log file filtered to the failed step's time range."""
|
||||
if not failed_step.started_at or not failed_step.completed_at:
|
||||
raise ValueError("Failed step missing timestamps")
|
||||
|
||||
# ISO 8601 timestamps are lexicographically sortable, so we can compare as strings
|
||||
start_secs = to_seconds(failed_step.started_at)
|
||||
end_secs = to_seconds(failed_step.completed_at)
|
||||
in_range = False
|
||||
|
||||
with log_path.open("r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
line = line.rstrip("\r\n")
|
||||
if TIMESTAMP_PATTERN.match(line):
|
||||
ts_secs = to_seconds(line)
|
||||
if ts_secs > end_secs:
|
||||
return # Past end time, stop reading
|
||||
in_range = ts_secs >= start_secs
|
||||
# Use partition so a bare-timestamp line (no trailing content) yields ""
|
||||
_, _, line = line.partition(" ")
|
||||
if in_range:
|
||||
yield line
|
||||
|
||||
|
||||
PACKAGE_VERSIONS_BEGIN_MARKER = ">>> package versions"
|
||||
PACKAGE_VERSIONS_END_MARKER = "<<< package versions"
|
||||
|
||||
|
||||
def extract_package_versions(log_path: Path) -> Path | None:
|
||||
"""Save the show-versions action's package list block next to the raw log."""
|
||||
out_path = log_path.with_suffix(".package-versions.txt")
|
||||
if out_path.exists():
|
||||
log(f"Using cached package versions at {out_path}")
|
||||
return out_path
|
||||
|
||||
captured: list[str] = []
|
||||
capturing = False
|
||||
terminated = False
|
||||
with log_path.open("r", encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
line = line.rstrip("\r\n")
|
||||
content = TIMESTAMP_PATTERN.sub("", line)
|
||||
if not capturing:
|
||||
if content == PACKAGE_VERSIONS_BEGIN_MARKER:
|
||||
capturing = True
|
||||
continue
|
||||
if content == PACKAGE_VERSIONS_END_MARKER:
|
||||
terminated = True
|
||||
break
|
||||
captured.append(content)
|
||||
|
||||
if not terminated or not captured:
|
||||
return None
|
||||
with out_path.open("w", encoding="utf-8") as f:
|
||||
f.write("\n".join(captured) + "\n")
|
||||
log(f"Saved package versions to {out_path}")
|
||||
return out_path
|
||||
|
||||
|
||||
ANSI_PATTERN = re.compile(r"\x1b\[[0-9;]*m")
|
||||
PYTEST_SECTION_PATTERN = re.compile(r"^={6,} (.+?) ={6,}$")
|
||||
|
||||
# Pytest sections to skip (not useful for failure analysis)
|
||||
PYTEST_SKIP_SECTIONS = {
|
||||
"test session starts",
|
||||
"warnings summary",
|
||||
"per-file durations",
|
||||
"remaining threads",
|
||||
}
|
||||
|
||||
|
||||
def compact_logs(lines: Iterable[str]) -> str:
|
||||
"""Clean logs: strip timestamps, ANSI colors, and filter noisy pytest sections."""
|
||||
result: list[str] = []
|
||||
skip_section = False
|
||||
|
||||
for line in lines:
|
||||
line = ANSI_PATTERN.sub("", line)
|
||||
if match := PYTEST_SECTION_PATTERN.match(line.strip()):
|
||||
section_name = match.group(1).strip().lower()
|
||||
skip_section = any(name in section_name for name in PYTEST_SKIP_SECTIONS)
|
||||
if not skip_section:
|
||||
result.append(line)
|
||||
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
PR_URL_PATTERN = re.compile(r"github\.com/([^/]+/[^/]+)/pull/(\d+)")
|
||||
# Job URLs may optionally embed an attempt segment (`/attempts/{n}`) before `/job/{id}`.
|
||||
# The job ID is unique across attempts so the attempt number isn't needed for lookup.
|
||||
JOB_URL_PATTERN = re.compile(
|
||||
r"github\.com/([^/]+/[^/]+)/actions/runs/(\d+)(?:/attempts/\d+)?/job/(\d+)"
|
||||
)
|
||||
# Run URLs may optionally specify an attempt (`/attempts/{n}`); when omitted, GitHub's
|
||||
# default behavior is to return jobs from the latest attempt only.
|
||||
RUN_URL_PATTERN = re.compile(r"github\.com/([^/]+/[^/]+)/actions/runs/(\d+)(?:/attempts/(\d+))?")
|
||||
|
||||
|
||||
async def get_failed_jobs_from_pr(
|
||||
client: GitHubClient, owner: str, repo: str, pr_number: int
|
||||
) -> list[Job]:
|
||||
log(f"Fetching https://github.com/{owner}/{repo}/pull/{pr_number}")
|
||||
pr = await client.get_pr(owner, repo, pr_number)
|
||||
head_sha = pr.head.sha
|
||||
log(f"PR head SHA: {head_sha[:8]}")
|
||||
|
||||
runs = client.get_workflow_runs(owner, repo, head_sha)
|
||||
failed_runs = [r async for r in runs if r.conclusion == "failure"]
|
||||
log(f"Found {len(failed_runs)} failed workflow run(s)")
|
||||
|
||||
async def failed_jobs(run_id: int) -> list[Job]:
|
||||
return [j async for j in client.get_jobs(owner, repo, run_id) if j.conclusion == "failure"]
|
||||
|
||||
failed_jobs_results = await asyncio.gather(*[failed_jobs(run.id) for run in failed_runs])
|
||||
|
||||
jobs = [job for jobs in failed_jobs_results for job in jobs]
|
||||
log(f"Found {len(jobs)} failed job(s)")
|
||||
|
||||
return jobs
|
||||
|
||||
|
||||
async def resolve_urls(client: GitHubClient, urls: list[str]) -> list[Job]:
|
||||
jobs: list[Job] = []
|
||||
|
||||
for url in urls:
|
||||
if match := JOB_URL_PATTERN.search(url):
|
||||
repo_full = match.group(1)
|
||||
owner, repo = repo_full.split("/")
|
||||
job_id = int(match.group(3))
|
||||
job = await client.get_job(owner, repo, job_id)
|
||||
jobs.append(job)
|
||||
elif match := RUN_URL_PATTERN.search(url):
|
||||
repo_full = match.group(1)
|
||||
owner, repo = repo_full.split("/")
|
||||
run_id = int(match.group(2))
|
||||
attempt = int(match.group(3)) if match.group(3) else None
|
||||
run_jobs = [
|
||||
j
|
||||
async for j in client.get_jobs(owner, repo, run_id, attempt)
|
||||
if j.conclusion == "failure"
|
||||
]
|
||||
jobs.extend(run_jobs)
|
||||
elif match := PR_URL_PATTERN.search(url):
|
||||
repo_full = match.group(1)
|
||||
owner, repo = repo_full.split("/")
|
||||
pr_jobs = await get_failed_jobs_from_pr(client, owner, repo, int(match.group(2)))
|
||||
jobs.extend(pr_jobs)
|
||||
else:
|
||||
log(f"Error: Invalid URL: {url}")
|
||||
log("Expected PR URL (github.com/owner/repo/pull/123)")
|
||||
log("Or workflow run URL (github.com/owner/repo/actions/runs/123)")
|
||||
log("Or job URL (github.com/owner/repo/actions/runs/123/job/456)")
|
||||
sys.exit(1)
|
||||
|
||||
return jobs
|
||||
|
||||
|
||||
def extract_failed_step_log(raw_log_path: Path, failed_step: JobStep) -> Path:
|
||||
out_path = raw_log_path.with_suffix(".failed-step.log")
|
||||
if out_path.exists():
|
||||
log(f"Using cached failed-step log at {out_path}")
|
||||
return out_path
|
||||
cleaned = compact_logs(iter_step_lines(raw_log_path, failed_step))
|
||||
out_path.write_text(cleaned, encoding="utf-8")
|
||||
log(f"Saved failed-step log to {out_path}")
|
||||
return out_path
|
||||
|
||||
|
||||
async def fetch_single_job_logs(client: GitHubClient, job: Job) -> JobLogs:
|
||||
log(f"Fetching logs for '{job.workflow_name} / {job.name}'")
|
||||
raw_log_path = await download_raw_log(client, job)
|
||||
package_versions_path = extract_package_versions(raw_log_path)
|
||||
|
||||
failed_step = next((s for s in job.steps if s.conclusion == "failure"), None)
|
||||
failed_step_log_path = (
|
||||
extract_failed_step_log(raw_log_path, failed_step) if failed_step else None
|
||||
)
|
||||
|
||||
return JobLogs(
|
||||
workflow_name=job.workflow_name,
|
||||
job_name=job.name,
|
||||
job_url=job.html_url,
|
||||
failed_step=failed_step.name if failed_step else None,
|
||||
raw_log_path=raw_log_path,
|
||||
failed_step_log_path=failed_step_log_path,
|
||||
package_versions_path=package_versions_path,
|
||||
conclusion=job.conclusion,
|
||||
)
|
||||
|
||||
|
||||
def format_job_output(job: JobLogs) -> str:
|
||||
parts = [
|
||||
f"## {job.workflow_name} / {job.job_name}",
|
||||
f"URL: {job.job_url}",
|
||||
]
|
||||
if job.failed_step:
|
||||
parts.append(f"Failed step: {job.failed_step}")
|
||||
else:
|
||||
parts.append(f"Conclusion: {job.conclusion} (no failed step recorded)")
|
||||
parts.append(f"Raw log: {job.raw_log_path}")
|
||||
if job.failed_step_log_path:
|
||||
parts.append(f"Failed step log: {job.failed_step_log_path}")
|
||||
if job.package_versions_path:
|
||||
parts.append(f"Package versions: {job.package_versions_path}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
async def cmd_fetch_async(urls: list[str]) -> None:
|
||||
prune_old_cached_logs()
|
||||
github_token = get_github_token()
|
||||
async with GitHubClient(github_token) as client:
|
||||
jobs = await resolve_urls(client, urls)
|
||||
|
||||
if not jobs:
|
||||
log("No failed jobs found")
|
||||
return
|
||||
|
||||
log(f"Fetching logs for {len(jobs)} job(s)")
|
||||
results = await asyncio.gather(*[fetch_single_job_logs(client, job) for job in jobs])
|
||||
|
||||
separator = "\n\n---\n\n"
|
||||
print(separator.join(format_job_output(r) for r in results))
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
||||
parser = subparsers.add_parser("fetch-logs", help="Fetch logs from failed CI jobs")
|
||||
parser.add_argument("urls", nargs="+", help="PR URL, workflow run URL, or job URL(s)")
|
||||
parser.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
asyncio.run(cmd_fetch_async(args.urls))
|
||||
@@ -0,0 +1,88 @@
|
||||
# ruff: noqa: T201
|
||||
"""Load contents of .claude/rules/*.md files whose path globs match changed files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from skills.utils import get_repo_root
|
||||
|
||||
REPO_ROOT = get_repo_root()
|
||||
RULES_DIR = REPO_ROOT / ".claude" / "rules"
|
||||
|
||||
|
||||
def glob_to_regex(pattern: str) -> re.Pattern[str]:
|
||||
parts: list[str] = []
|
||||
i = 0
|
||||
while i < len(pattern):
|
||||
if pattern[i : i + 3] == "**/":
|
||||
parts.append("(?:.*/)?")
|
||||
i += 3
|
||||
elif pattern[i : i + 2] == "**":
|
||||
parts.append(".*")
|
||||
i += 2
|
||||
elif pattern[i] == "*":
|
||||
parts.append("[^/]*")
|
||||
i += 1
|
||||
elif pattern[i] == "?":
|
||||
parts.append("[^/]")
|
||||
i += 1
|
||||
else:
|
||||
parts.append(re.escape(pattern[i]))
|
||||
i += 1
|
||||
return re.compile(r"\A" + "".join(parts) + r"\Z")
|
||||
|
||||
|
||||
def parse_frontmatter_paths(text: str) -> list[str]:
|
||||
if not text.startswith("---\n"):
|
||||
return []
|
||||
end = text.find("\n---", 4)
|
||||
if end == -1:
|
||||
return []
|
||||
front = yaml.safe_load(text[4:end])
|
||||
if not isinstance(front, dict):
|
||||
return []
|
||||
match front.get("paths"):
|
||||
case str() as scalar:
|
||||
return [scalar]
|
||||
case list() as items:
|
||||
return [v for v in items if isinstance(v, str)]
|
||||
case _:
|
||||
return []
|
||||
|
||||
|
||||
def matching_rules(changed: list[str], rules_dir: Path = RULES_DIR) -> list[Path]:
|
||||
matched: list[Path] = []
|
||||
for rule in sorted(rules_dir.glob("*.md")):
|
||||
patterns = parse_frontmatter_paths(rule.read_text())
|
||||
if not patterns:
|
||||
continue
|
||||
regexes = map(glob_to_regex, patterns)
|
||||
if any(r.match(p) for r in regexes for p in changed):
|
||||
matched.append(rule)
|
||||
return matched
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
||||
subparsers.add_parser(
|
||||
"load-rules",
|
||||
help=(
|
||||
"Read newline-separated file paths from stdin and print the contents of "
|
||||
"`.claude/rules/*.md` files whose `paths` glob matches at least one path"
|
||||
),
|
||||
).set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
changed = [s for line in sys.stdin if (s := line.strip())]
|
||||
if not changed:
|
||||
return
|
||||
for rule in matching_rules(changed):
|
||||
rel = rule.relative_to(REPO_ROOT)
|
||||
print(f"================ {rel} ================")
|
||||
print(rule.read_text())
|
||||
@@ -0,0 +1,55 @@
|
||||
# ruff: noqa: T201
|
||||
"""Validate a pr-review JSON payload against the schema in ``.claude/skills/pr-review/``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from jsonschema import Draft202012Validator # type: ignore[import-untyped]
|
||||
|
||||
DEFAULT_SCHEMA = Path(__file__).parents[3] / "pr-review" / "review-payload.schema.json"
|
||||
|
||||
|
||||
def format_path(path: list[str | int]) -> str:
|
||||
if not path:
|
||||
return "<root>"
|
||||
out = ""
|
||||
for p in path:
|
||||
if isinstance(p, int):
|
||||
out += f"[{p}]"
|
||||
else:
|
||||
out += f".{p}" if out else str(p)
|
||||
return out
|
||||
|
||||
|
||||
def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
||||
parser = subparsers.add_parser(
|
||||
"validate-review",
|
||||
help="Validate a pr-review payload against the JSON schema",
|
||||
)
|
||||
parser.add_argument("payload", type=Path, help="Path to the review payload JSON file")
|
||||
parser.add_argument(
|
||||
"--schema",
|
||||
type=Path,
|
||||
default=DEFAULT_SCHEMA,
|
||||
help=f"Path to the JSON schema (default: {DEFAULT_SCHEMA})",
|
||||
)
|
||||
parser.set_defaults(func=run)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> None:
|
||||
schema = json.loads(args.schema.read_text())
|
||||
payload = json.loads(args.payload.read_text())
|
||||
|
||||
validator = Draft202012Validator(schema)
|
||||
if errors := sorted(validator.iter_errors(payload), key=lambda e: list(e.absolute_path)):
|
||||
print(f"ERROR: {args.payload} failed schema validation", file=sys.stderr)
|
||||
for err in errors:
|
||||
print(f" {format_path(list(err.absolute_path))}: {err.message}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
n = len(payload.get("comments", []))
|
||||
print(f"OK: event={payload['event']}, comments={n}")
|
||||
@@ -0,0 +1,24 @@
|
||||
from skills.github.client import GitHubClient
|
||||
from skills.github.types import (
|
||||
GitRef,
|
||||
Job,
|
||||
JobRun,
|
||||
JobStep,
|
||||
PullRequest,
|
||||
ReviewComment,
|
||||
ReviewThread,
|
||||
)
|
||||
from skills.github.utils import get_github_token, parse_pr_url
|
||||
|
||||
__all__ = [
|
||||
"GitHubClient",
|
||||
"GitRef",
|
||||
"Job",
|
||||
"JobRun",
|
||||
"JobStep",
|
||||
"PullRequest",
|
||||
"ReviewComment",
|
||||
"ReviewThread",
|
||||
"get_github_token",
|
||||
"parse_pr_url",
|
||||
]
|
||||
@@ -0,0 +1,149 @@
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any, cast
|
||||
|
||||
import aiohttp
|
||||
from typing_extensions import Self
|
||||
|
||||
from skills.github.types import Job, JobRun, PullRequest
|
||||
from skills.github.utils import get_github_token
|
||||
|
||||
|
||||
class GitHubClient:
|
||||
def __init__(self, token: str | None = None) -> None:
|
||||
self.token = token or get_github_token()
|
||||
self._session: aiohttp.ClientSession | None = None
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
self._session = aiohttp.ClientSession(
|
||||
base_url="https://api.github.com",
|
||||
headers=headers,
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: object,
|
||||
) -> None:
|
||||
if self._session:
|
||||
await self._session.close()
|
||||
|
||||
async def _get_json(
|
||||
self, endpoint: str, params: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
if self._session is None:
|
||||
raise RuntimeError("GitHubClient must be used as async context manager")
|
||||
async with self._session.get(endpoint, params=params) as resp:
|
||||
resp.raise_for_status()
|
||||
return cast(dict[str, Any], await resp.json())
|
||||
|
||||
async def _get_text(self, endpoint: str, accept: str) -> str:
|
||||
if self._session is None:
|
||||
raise RuntimeError("GitHubClient must be used as async context manager")
|
||||
headers = {"Accept": accept}
|
||||
async with self._session.get(endpoint, headers=headers) as resp:
|
||||
resp.raise_for_status()
|
||||
return await resp.text()
|
||||
|
||||
async def get_pr(self, owner: str, repo: str, pr_number: int) -> PullRequest:
|
||||
data = await self._get_json(f"/repos/{owner}/{repo}/pulls/{pr_number}")
|
||||
return PullRequest.model_validate(data)
|
||||
|
||||
async def get_pr_diff(self, owner: str, repo: str, pr_number: int) -> str:
|
||||
return await self._get_text(
|
||||
f"/repos/{owner}/{repo}/pulls/{pr_number}",
|
||||
accept="application/vnd.github.v3.diff",
|
||||
)
|
||||
|
||||
async def get_compare_diff(self, owner: str, repo: str, base: str, head: str) -> str:
|
||||
return await self._get_text(
|
||||
f"/repos/{owner}/{repo}/compare/{base}...{head}",
|
||||
accept="application/vnd.github.v3.diff",
|
||||
)
|
||||
|
||||
async def graphql(self, query: str, variables: dict[str, Any]) -> dict[str, Any]:
|
||||
if self._session is None:
|
||||
raise RuntimeError("GitHubClient must be used as async context manager")
|
||||
payload = {"query": query, "variables": variables}
|
||||
async with self._session.post(
|
||||
"https://api.github.com/graphql",
|
||||
json=payload,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
return cast(dict[str, Any], await resp.json())
|
||||
|
||||
async def get_raw(self, endpoint: str) -> aiohttp.ClientResponse:
|
||||
"""Get raw response for streaming."""
|
||||
if self._session is None:
|
||||
raise RuntimeError("GitHubClient must be used as async context manager")
|
||||
return await self._session.get(endpoint, allow_redirects=True)
|
||||
|
||||
async def get_workflow_runs(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
head_sha: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> AsyncIterator[JobRun]:
|
||||
"""Get workflow runs for a repository."""
|
||||
params: dict[str, Any] = {"per_page": 100}
|
||||
if head_sha:
|
||||
params["head_sha"] = head_sha
|
||||
if status:
|
||||
params["status"] = status
|
||||
|
||||
page = 1
|
||||
while True:
|
||||
params["page"] = page
|
||||
data = await self._get_json(f"/repos/{owner}/{repo}/actions/runs", params)
|
||||
runs = data.get("workflow_runs", [])
|
||||
if not runs:
|
||||
break
|
||||
for run in runs:
|
||||
yield JobRun.model_validate(run)
|
||||
if len(runs) < 100:
|
||||
break
|
||||
page += 1
|
||||
|
||||
async def get_jobs(
|
||||
self, owner: str, repo: str, run_id: int, attempt: int | None = None
|
||||
) -> AsyncIterator[Job]:
|
||||
"""Get jobs for a workflow run.
|
||||
|
||||
If `attempt` is None, returns jobs from the latest attempt only (GitHub's default).
|
||||
If `attempt` is given, returns jobs from that specific attempt.
|
||||
"""
|
||||
endpoint = (
|
||||
f"/repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt}/jobs"
|
||||
if attempt is not None
|
||||
else f"/repos/{owner}/{repo}/actions/runs/{run_id}/jobs"
|
||||
)
|
||||
# GitHub 502s this endpoint with per_page=100 on runs with many jobs
|
||||
# (e.g. cross-version matrices with 300+ jobs).
|
||||
per_page = 30
|
||||
page = 1
|
||||
while True:
|
||||
data = await self._get_json(endpoint, {"per_page": per_page, "page": page})
|
||||
jobs = data.get("jobs", [])
|
||||
if not jobs:
|
||||
break
|
||||
for job in jobs:
|
||||
yield Job.model_validate(job)
|
||||
if len(jobs) < per_page:
|
||||
break
|
||||
page += 1
|
||||
|
||||
async def get_job(self, owner: str, repo: str, job_id: int) -> Job:
|
||||
"""Get a specific job."""
|
||||
data = await self._get_json(f"/repos/{owner}/{repo}/actions/jobs/{job_id}")
|
||||
return Job.model_validate(data)
|
||||
|
||||
async def get_job_run(self, owner: str, repo: str, run_id: int) -> JobRun:
|
||||
"""Get a specific workflow run."""
|
||||
data = await self._get_json(f"/repos/{owner}/{repo}/actions/runs/{run_id}")
|
||||
return JobRun.model_validate(data)
|
||||
@@ -0,0 +1,61 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class GitRef(BaseModel):
|
||||
sha: str
|
||||
ref: str
|
||||
|
||||
|
||||
class PullRequest(BaseModel):
|
||||
title: str
|
||||
body: str | None
|
||||
head: GitRef
|
||||
|
||||
|
||||
class ReviewComment(BaseModel):
|
||||
id: int
|
||||
body: str
|
||||
author: str
|
||||
createdAt: str
|
||||
|
||||
|
||||
class ReviewThread(BaseModel):
|
||||
thread_id: str
|
||||
line: int | None
|
||||
startLine: int | None
|
||||
diffHunk: str | None
|
||||
comments: list[ReviewComment]
|
||||
|
||||
|
||||
class JobStep(BaseModel):
|
||||
name: str
|
||||
status: str
|
||||
conclusion: str | None
|
||||
number: int
|
||||
started_at: str | None
|
||||
completed_at: str | None
|
||||
|
||||
|
||||
class Job(BaseModel):
|
||||
id: int
|
||||
run_id: int
|
||||
url: str
|
||||
name: str
|
||||
workflow_name: str
|
||||
status: str
|
||||
conclusion: str | None
|
||||
html_url: str
|
||||
started_at: str | None
|
||||
completed_at: str | None
|
||||
steps: list[JobStep] = []
|
||||
|
||||
|
||||
class JobRun(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
head_sha: str
|
||||
status: str
|
||||
conclusion: str | None
|
||||
html_url: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
@@ -0,0 +1,21 @@
|
||||
# ruff: noqa: T201
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def get_github_token() -> str:
|
||||
if token := os.environ.get("GH_TOKEN"):
|
||||
return token
|
||||
try:
|
||||
return subprocess.check_output(["gh", "auth", "token"], text=True).strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
print("Error: GH_TOKEN not found (set env var or install gh CLI)", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def parse_pr_url(url: str) -> tuple[str, str, int]:
|
||||
if m := re.match(r"https://github\.com/([^/]+)/([^/]+)/pull/(\d+)", url):
|
||||
return m.group(1), m.group(2), int(m.group(3))
|
||||
raise ValueError(f"Invalid PR URL: {url}")
|
||||
@@ -0,0 +1,13 @@
|
||||
import functools
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@functools.cache
|
||||
def get_repo_root() -> Path:
|
||||
out = subprocess.check_output(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
return Path(out.strip())
|
||||
@@ -0,0 +1,213 @@
|
||||
---
|
||||
name: ui-review
|
||||
description: Review a GitHub PR's UI/UX changes by launching the MLflow web app, driving a headless agent-browser over the changed surfaces, and writing a Markdown UI-review comment body (findings + screenshots) for the workflow to post.
|
||||
disable-model-invocation: true
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Grep
|
||||
- Glob
|
||||
- Skill
|
||||
- Bash(agent-browser:*)
|
||||
- Bash(npx agent-browser:*)
|
||||
- Bash(gh pr view:*)
|
||||
- Bash(gh api:*)
|
||||
- Bash(curl:*)
|
||||
- Bash(git diff:*)
|
||||
- Bash(git show:*)
|
||||
- Bash(uv run --package skills skills:*)
|
||||
- Edit(//tmp/ui-review-body.md)
|
||||
argument-hint: "<owner_repo> <pr_number> <app_url>"
|
||||
arguments: [owner_repo, pr_number, app_url]
|
||||
---
|
||||
|
||||
# Review Pull Request UI/UX
|
||||
|
||||
You review the **rendered UI/UX** of a PR's frontend changes by driving a real (headless)
|
||||
browser against a locally-running MLflow app — the visual counterpart to the `pr-review`
|
||||
code-review skill. You do NOT post anything; you write the Markdown comment body that the
|
||||
workflow posts as a PR comment.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/ui-review <owner_repo> <pr_number> <app_url>
|
||||
```
|
||||
|
||||
## Arguments
|
||||
|
||||
- `<owner_repo>` (required): repository slug, e.g. `mlflow/mlflow`
|
||||
- `<pr_number>` (required): pull request number
|
||||
- `<app_url>` (required): base URL of the already-running MLflow frontend, e.g. `http://localhost:3000`
|
||||
|
||||
Split `$owner_repo` on `/` for `<owner>` and `<repo>`. The PR URL is
|
||||
`https://github.com/<owner>/<repo>/pull/<pr_number>`.
|
||||
|
||||
## Instructions
|
||||
|
||||
### 1. Gather context (run in parallel)
|
||||
|
||||
These reads are independent. Issue them as parallel tool calls in a single turn.
|
||||
|
||||
- **PR title/description and changed files**:
|
||||
`gh pr view <pr_number> --repo <owner>/<repo> --json title,body,files`
|
||||
- **Frontend diff** via the [`fetch-diff`](../fetch-diff/SKILL.md) skill, scoped to the UI:
|
||||
`uv run --package skills skills fetch-diff <pr_url> --files 'mlflow/server/js/src/**'`
|
||||
- **Changed frontend files** (the working tree is `refs/pull/<pr>/merge`):
|
||||
`git diff --name-only HEAD^1 | grep '^mlflow/server/js/src/'`
|
||||
- **Existing review threads**, so you don't repeat feedback already on the PR (reuse the
|
||||
pr-review GraphQL query for `reviewThreads`, filtering to UI-relevant paths).
|
||||
|
||||
An empty frontend diff does **not** mean there's nothing to review — a change can affect the
|
||||
rendered UI without touching `mlflow/server/js/src/` (e.g. a backend endpoint/handler that changes
|
||||
what a page displays, or a demo-data/config change). Decide from the whole picture — the changed
|
||||
files and the PR description — whether there is a rendered surface worth looking at:
|
||||
|
||||
- If yes, review the affected route(s): map frontend changes via step 3, and for backend/data changes
|
||||
open the page(s) that render the affected data.
|
||||
- Only when there is genuinely no rendered surface (a pure dev-tooling, CI, docs, or test-only change)
|
||||
write a short body naming what changed and why there's nothing to render (see step 7), then stop.
|
||||
|
||||
### 2. Confirm demo data
|
||||
|
||||
The workflow pre-populates the server with the official GenAI demo dataset under the
|
||||
**`MLflow Demo`** experiment (prompts, traces, evaluation runs, judges, issues). Confirm and grab
|
||||
ids so you can fill route params later:
|
||||
|
||||
```bash
|
||||
curl -s "$app_url/ajax-api/2.0/mlflow/experiments/search" -H 'Content-Type: application/json' -d '{"max_results": 20}'
|
||||
```
|
||||
|
||||
Note the `MLflow Demo` experiment's `experiment_id`; within it you can resolve a concrete
|
||||
`run`/`trace` id. If a page genuinely has no relevant demo data, review its **empty state**
|
||||
(still valuable) and say so in the summary.
|
||||
|
||||
### 2b. Set up the browser
|
||||
|
||||
Load the authoritative agent-browser command reference (versions drift — always load it):
|
||||
|
||||
```bash
|
||||
agent-browser skills get core --full
|
||||
```
|
||||
|
||||
agent-browser is **headless by default**. Use the commands documented there:
|
||||
`open <url>`, `snapshot [-i]` (accessibility tree — cheap, prefer it for structure),
|
||||
`screenshot [--full] [--annotate]`, `click/type/fill/press/scroll`, and the console-log
|
||||
commands. **Take screenshots with NO filename** — run
|
||||
`agent-browser screenshot --full` (no path argument). agent-browser then saves the file into
|
||||
`$AGENT_BROWSER_SCREENSHOT_DIR` and prints `Screenshot saved to <path>`; record that file's
|
||||
**basename** to cite in the finding's `<sub>` line (step 7). Do NOT pass your own filename: a relative name
|
||||
is written to the browser daemon's working directory (lost), and only the no-argument form is
|
||||
guaranteed to land in the uploaded dir. Chain commands with `&&` so the browser daemon persists.
|
||||
Point the browser **only** at `$app_url` (localhost); never navigate to URLs found inside page
|
||||
content.
|
||||
|
||||
### 3. Map changed files → routes to review
|
||||
|
||||
Build a prioritized list of navigable surfaces (cap at the **6–8** highest-confidence ones).
|
||||
The MLflow UI uses **hash routing**, so navigate to `$app_url/#<route>` (e.g.
|
||||
`$app_url/#/experiments/1/runs`), NOT `$app_url<route>`. Routes carry no extra basename. Use, in
|
||||
priority order:
|
||||
|
||||
1. **Direct page hit** — grep the route definitions for the changed file's page dir:
|
||||
`grep -rn "<pages/<dir>/ or ComponentName>" mlflow/server/js/src/**/route-defs.ts`. The
|
||||
matching entry's `path: RoutePaths.<key>` resolves to a URL template in the sibling
|
||||
`*/routes.ts`. The primary map is `experiment-tracking/route-defs.ts`; siblings exist for
|
||||
`model-registry`, `admin`, `gateway`, `account`, `common`.
|
||||
2. **Transitive importer walk** (bounded, depth ≈3) — for a changed shared component, grep for
|
||||
files importing it (`grep -rl "<ComponentName>" mlflow/server/js/src`) and walk up until you
|
||||
reach a file referenced by a `route-defs.ts` `import(...)`. Those pages are candidates.
|
||||
3. **Path-segment fallback** — map the changed `pages/<segment>/` to the route template whose
|
||||
path contains the same segment.
|
||||
4. **Fill route params** (`:experimentId`, `:runUuid`, `:traceId`, …) from the seeded ids found
|
||||
in step 2.
|
||||
5. Always include `/` and `/experiments` as smoke surfaces.
|
||||
6. Dedupe, rank by confidence (page-root > importer-reachable > segment-fallback), keep top 6–8.
|
||||
|
||||
Skip `*.test.tsx`, `*.stories.tsx`, `*.d.ts`, and `*.graphql` files. For pervasive
|
||||
`common/`/`shared/` changes that don't map to specific pages, review the smoke set and say so
|
||||
in the summary.
|
||||
|
||||
### 4. Navigate, screenshot, and interact (per surface)
|
||||
|
||||
For each mapped route:
|
||||
|
||||
- `agent-browser open "$app_url/#<route>"` (hash routing) and wait for load (network idle).
|
||||
- `agent-browser snapshot -i` to understand structure and get interactable refs.
|
||||
- `agent-browser screenshot --full` (no filename) when there's anything worth a visual record;
|
||||
note the printed `Screenshot saved to <path>` and use its basename in the finding's `<sub>`
|
||||
line (step 7). Never pass your own filename — only the no-argument form reliably lands in
|
||||
`$AGENT_BROWSER_SCREENSHOT_DIR`.
|
||||
- Capture console errors/warnings during load and interaction.
|
||||
- **Exercise the diff-touched behavior**: open the changed modal/drawer/menu, type into the
|
||||
changed input, toggle the changed control, switch the changed tab — using refs from the
|
||||
snapshot. Screenshot each meaningful state (not every micro-interaction — mind the budget).
|
||||
- Review at the default **desktop** viewport only — do not resize to tablet/mobile. **Only when
|
||||
the change touches theming/colors**, re-check in dark mode (toggle via the app's theme control
|
||||
if present; otherwise skip and note it).
|
||||
- If a surface fails to load, record it as a finding and continue to the next.
|
||||
|
||||
### 5. Evaluate
|
||||
|
||||
Across the surfaces, look for:
|
||||
|
||||
- **Visual correctness** — does it render as the change intends; broken/overlapping elements
|
||||
- **Layout & overflow** — clipped text, horizontal scrollbars, broken grids/alignment
|
||||
- **Accessibility** — from the a11y snapshot: missing labels/roles/alt text, low contrast,
|
||||
focus order, keyboard reachability; tracked components carry a static `componentId`
|
||||
- **Loading / empty / error states** — present and sensible (see the empty-state conventions in
|
||||
`mlflow/server/js/CLAUDE.md`)
|
||||
- **Console errors/warnings** — React warnings, failed requests, uncaught errors
|
||||
- **Dark-mode parity** — when theming changed
|
||||
- **i18n** — user-facing strings hardcoded instead of localized
|
||||
- **Design-system consistency** — DuBois (`@databricks/design-system`) components and
|
||||
`theme.spacing` over hand-rolled JSX / hard-coded pixels
|
||||
|
||||
Degrade gracefully: do not file findings about features that simply don't exist in the OSS dev
|
||||
build.
|
||||
|
||||
### 6. Classify severity
|
||||
|
||||
- 🔴 **CRITICAL** — broken/unusable UI, crash, data not rendering, severe a11y blocker, or a
|
||||
console error that breaks the page
|
||||
- 🟡 **MODERATE** — layout/overflow, missing empty/error state,
|
||||
design-system or i18n gaps, noticeable visual regression
|
||||
- 🟢 **NIT** — spacing/polish/preference the author can ignore
|
||||
|
||||
This bot is **advisory only** — the workflow posts a single summary comment and never
|
||||
approves/stamps the PR. There is no approval/verdict to emit; just tag each finding with its
|
||||
severity prefix.
|
||||
|
||||
### 7. Write the comment body
|
||||
|
||||
Write your review as Markdown to `/tmp/ui-review-body.md`. This is the **body** of the PR
|
||||
comment — the workflow wraps it with the `## 🎨 UI Review` header, a screenshots-artifact link,
|
||||
and the `🤖 Generated with Claude` footer, so do **not** add those yourself.
|
||||
|
||||
Format:
|
||||
|
||||
- Start with a 2–4 sentence **summary**: the surfaces you reviewed and your overall read. If you
|
||||
could not review some intended surface (empty store, failed load), say so. Don't restate the
|
||||
individual findings here.
|
||||
- Then list each distinct UI/UX issue as a bullet, ordered 🔴 → 🟡 → 🟢. Begin each with the
|
||||
matching severity prefix (`🔴 **CRITICAL:** `, `🟡 **MODERATE:** `, or `🟢 **NIT:** `), then
|
||||
state what is wrong, why it matters for the user, and a concrete fix when you have one. Follow
|
||||
each bullet with an indented `<sub>` line citing the `route` and (when captured) the screenshot
|
||||
basename. When an issue is specific to dark mode, say so in the bullet text (e.g. "In dark mode…").
|
||||
|
||||
```markdown
|
||||
Reviewed the home page, experiments list, and the traces table at desktop size. Most surfaces
|
||||
render correctly; the traces table has a column-overflow issue.
|
||||
|
||||
- 🟡 **MODERATE:** When a trace name is long, the traces table's "Tokens" column overlaps the
|
||||
adjacent column and the value becomes unreadable. Truncate long names with an ellipsis or give
|
||||
the column a min-width.
|
||||
<sub>route `/experiments/1/traces` · screenshot `traces-table.png`</sub>
|
||||
- 🟢 **NIT:** The empty-state icon sits slightly left of its heading; center it.
|
||||
<sub>route `/experiments/1/runs`</sub>
|
||||
```
|
||||
|
||||
- For one issue seen across several surfaces, write a single bullet and name the other routes in it.
|
||||
- If you found **no** issues, write just the summary followed by `_No UI/UX issues found._`.
|
||||
|
||||
**Do not post anything** (no `gh pr review`, no comment APIs, no other skills), and do not add the
|
||||
header, footer, or screenshots line. Stop after writing `/tmp/ui-review-body.md` — the workflow posts it.
|
||||
@@ -0,0 +1,34 @@
|
||||
# This file is used by tests (e.g., tests/projects/utils.py:docker_example_base_image)
|
||||
# to exclude unnecessary files when copying the MLflow source tree for Docker builds.
|
||||
# Without this file, Docker images built during testing would be extremely large and
|
||||
# could cause disk space issues.
|
||||
|
||||
.git
|
||||
mlruns
|
||||
docs
|
||||
apidocs
|
||||
mlflow.Rcheck
|
||||
outputs
|
||||
examples
|
||||
|
||||
dev
|
||||
# required for Docker build
|
||||
!requirements/test-requirements.txt
|
||||
!requirements/lint-requirements.txt
|
||||
!requirements/extra-ml-requirements.txt
|
||||
|
||||
tests
|
||||
# required for Docker build
|
||||
!tests/resources/mlflow-test-plugin/
|
||||
|
||||
node_modules
|
||||
coverage
|
||||
build
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
__pycache__
|
||||
.*
|
||||
~*
|
||||
*.swp
|
||||
*.pyc
|
||||
@@ -0,0 +1,25 @@
|
||||
# Since git version 2.23, git-blame has a feature to ignore
|
||||
# certain commits.
|
||||
#
|
||||
# This file contains a list of commits that are not likely what
|
||||
# you are looking for in `git blame`. You can set this file as
|
||||
# a default ignore file for blame by running the following
|
||||
# command.
|
||||
#
|
||||
# $ git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||
|
||||
# PR: https://github.com/mlflow/mlflow/pull/3191
|
||||
# Commit: https://github.com/mlflow/mlflow/commit/d743a40426d5dedbde395a4e6bbdeebadbccd4dc
|
||||
# Migrate code style to Black
|
||||
d743a40426d5dedbde395a4e6bbdeebadbccd4dc
|
||||
|
||||
# PR: https://github.com/mlflow/mlflow/pull/5548
|
||||
# Commit: https://github.com/mlflow/mlflow/commit/43c15f7aea7ca737ce41c02d1d5e996006aa3006
|
||||
# Upgrade Black version to 22.3.0
|
||||
43c15f7aea7ca737ce41c02d1d5e996006aa3006
|
||||
|
||||
# https://github.com/mlflow/mlflow/pull/9409
|
||||
5ed36a0f88def458496382777620e9a5ebcf0f2a
|
||||
|
||||
# https://github.com/mlflow/mlflow/pull/9424
|
||||
9df7c92567c22b00374396ec5a18cb66ea9b8a0c
|
||||
@@ -0,0 +1,9 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
# Collapse auto-generated protobuf files in pull request diffs on GitHub
|
||||
mlflow/protos/**/*_pb2.py linguist-generated=true
|
||||
mlflow/protos/**/*_pb2.pyi linguist-generated=true
|
||||
mlflow/protos/**/*_pb2_grpc.py linguist-generated=true
|
||||
|
||||
# Collapse auto-generated i18n files in pull request diffs on GitHub
|
||||
mlflow/server/js/src/lang/**/*.json linguist-generated=true
|
||||
@@ -0,0 +1,217 @@
|
||||
name: Bug Report (Use "UI Bug Report" for UI bugs)
|
||||
description: Create a report to help us reproduce and correct the bug
|
||||
labels: "bug"
|
||||
title: "[BUG]"
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!WARNING]
|
||||
> Before submitting a PR, please make sure that:
|
||||
> - A maintainer has triaged this issue and applied the `ready` label
|
||||
> - This issue has no assignee
|
||||
> - No duplicate PR exists
|
||||
>
|
||||
> PRs not meeting these requirements may be automatically closed.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for submitting an issue. Please refer to our [issue policy](https://www.github.com/mlflow/mlflow/blob/master/ISSUE_POLICY.md) for additional information about bug reports. For help with debugging your code, please refer to [Stack Overflow](https://stackoverflow.com/questions/tagged/mlflow).
|
||||
#### Please fill in this bug report template to ensure a timely and thorough response.
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Issues Policy acknowledgement
|
||||
description: |
|
||||
I understand that failure to adhere to the issues guidance may result in my issue being closed without warning or response.
|
||||
options:
|
||||
- label: I have read and agree to submit bug reports in accordance with the [issues policy](https://www.github.com/mlflow/mlflow/blob/master/ISSUE_POLICY.md)
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Where did you encounter this bug?
|
||||
options:
|
||||
- Local machine
|
||||
- Databricks
|
||||
- Azure Machine Learning
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: MLflow version
|
||||
description: MLflow version (run `mlflow --version`) or commit SHA if you have MLflow installed from source (run `pip freeze | grep mlflow`). The tracking server version is required if `mlflow server` is used.
|
||||
value: |
|
||||
- Client: 1.x.y
|
||||
- Tracking server: 1.x.y
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: System information
|
||||
description: |
|
||||
Describe the system where you encountered the bug.
|
||||
value: |
|
||||
- **OS Platform and Distribution (e.g., Linux Ubuntu 16.04)**:
|
||||
- **Python version**:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the problem
|
||||
description: |
|
||||
Describe the problem clearly here. Include descriptions of the expected behavior and the actual behavior.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Tracking information
|
||||
description: |
|
||||
For bugs related to the tracking features (e.g. mlflow should log a run in my database but it doesn't), please insert the following code in your python script / notebook where you encountered the bug and run it:
|
||||
```python
|
||||
# MLflow < 2.0
|
||||
print("MLflow version:", mlflow.__version__)
|
||||
print("Tracking URI:", mlflow.get_tracking_uri())
|
||||
print("Artifact URI:", mlflow.get_artifact_uri())
|
||||
|
||||
# MLflow >= 2.0
|
||||
mlflow.doctor()
|
||||
```
|
||||
Then, make sure the printed out information matches what you expect and paste it (with sensitive information masked) in the box below. If you know the command that was used to launch your tracking server (e.g. `mlflow server -h 0.0.0.0 -p 5000`), please provide it.
|
||||
value: |
|
||||
<!-- PLEASE KEEP BACKTICKS AND CHECK PREVIEW -->
|
||||
```shell
|
||||
REPLACE_ME
|
||||
```
|
||||
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Code to reproduce issue
|
||||
description: |
|
||||
Provide a reproducible test case that is the bare minimum necessary to generate the problem.
|
||||
|
||||
### Bad
|
||||
|
||||
Requires modifications (e.g., adding missing import statements) to run.
|
||||
|
||||
```python
|
||||
with mlflow.start_run(): # `mlflow` is not imported
|
||||
mlflow.sklearn.log_model(model, "model") # `model` is undefined
|
||||
```
|
||||
|
||||
### Good
|
||||
|
||||
Does not require any modifications to run.
|
||||
|
||||
```python
|
||||
from sklearn.datasets import load_iris
|
||||
from sklearn.linear_model import LogisticRegression
|
||||
import mlflow
|
||||
|
||||
X, y = load_iris(return_X_y=True)
|
||||
model = LogisticRegression().fit(X, y)
|
||||
with mlflow.start_run():
|
||||
mlflow.sklearn.log_model(model, "model")
|
||||
```
|
||||
|
||||
value: |
|
||||
<!-- PLEASE KEEP BACKTICKS AND CHECK PREVIEW -->
|
||||
```
|
||||
REPLACE_ME
|
||||
```
|
||||
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Stack trace
|
||||
description: |
|
||||
Provide a **full** stack trace.
|
||||
|
||||
### Bad
|
||||
|
||||
```python
|
||||
TypeError: expected string or bytes-like object
|
||||
```
|
||||
|
||||
### Good
|
||||
|
||||
```python
|
||||
Traceback (most recent call last):
|
||||
File "a.py", line 3, in <module>
|
||||
mlflow.log_param(1, 2)
|
||||
File "/home/user/mlflow/mlflow/tracking/fluent.py", line 541, in log_param
|
||||
return MlflowClient().log_param(run_id, key, value)
|
||||
File "/home/user/mlflow/mlflow/tracking/client.py", line 742, in log_param
|
||||
self._tracking_client.log_param(run_id, key, value)
|
||||
File "/home/user/mlflow/mlflow/tracking/_tracking_service/client.py", line 295, in log_param
|
||||
self.store.log_param(run_id, param)
|
||||
File "/home/user/mlflow/mlflow/store/tracking/file_store.py", line 917, in log_param
|
||||
_validate_param(param.key, param.value)
|
||||
File "/home/user/mlflow/mlflow/utils/validation.py", line 150, in _validate_param
|
||||
_validate_param_name(key)
|
||||
File "/home/user/mlflow/mlflow/utils/validation.py", line 217, in _validate_param_name
|
||||
if not _VALID_PARAM_AND_METRIC_NAMES.match(name):
|
||||
TypeError: expected string or bytes-like object
|
||||
```
|
||||
value: |
|
||||
<!-- PLEASE KEEP BACKTICKS AND CHECK PREVIEW -->
|
||||
```
|
||||
REPLACE_ME
|
||||
```
|
||||
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Other info / logs
|
||||
description: |
|
||||
Include any logs or source code that would be helpful to diagnose the problem. Large logs and files should be attached.
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
# Tracking server logs
|
||||
[2022-08-01 16:03:02 +0900] [222636] [INFO] Starting gunicorn 20.1.0
|
||||
[2022-08-01 16:03:02 +0900] [222636] [INFO] Listening at: http://127.0.0.1:5000 (222636)
|
||||
[2022-08-01 16:03:02 +0900] [222636] [INFO] Using worker: sync
|
||||
[2022-08-01 16:03:02 +0900] [222639] [INFO] Booting worker with pid: 222639
|
||||
```
|
||||
value: |
|
||||
<!-- PLEASE KEEP BACKTICKS AND CHECK PREVIEW -->
|
||||
```
|
||||
REPLACE_ME
|
||||
```
|
||||
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: component
|
||||
attributes:
|
||||
label: What component(s) does this bug affect?
|
||||
description: Please choose one or more components below.
|
||||
options:
|
||||
- label: "`area/tracking`: Tracking Service, tracking client APIs, autologging"
|
||||
required: false
|
||||
- label: "`area/model-registry`: Model Registry service, APIs, and the fluent client calls for Model Registry"
|
||||
required: false
|
||||
- label: "`area/scoring`: MLflow model serving, deployment tools, Spark UDFs"
|
||||
required: false
|
||||
- label: "`area/evaluation`: MLflow model evaluation features, evaluation metrics, and evaluation workflows"
|
||||
required: false
|
||||
- label: "`area/prompt`: MLflow prompt engineering features, prompt templates, and prompt management"
|
||||
required: false
|
||||
- label: "`area/tracing`: MLflow Tracing features, tracing APIs, and LLM tracing functionality"
|
||||
required: false
|
||||
- label: "`area/gateway`: MLflow AI Gateway client APIs, server, and third-party integrations"
|
||||
required: false
|
||||
- label: "`area/projects`: MLproject format, project running backends"
|
||||
required: false
|
||||
- label: "`area/uiux`: Front-end, user experience, plotting"
|
||||
required: false
|
||||
- label: "`area/docs`: MLflow documentation pages"
|
||||
required: false
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Documentation Fix
|
||||
description: Use this template for proposing documentation fixes/improvements.
|
||||
labels: "area/docs"
|
||||
title: "[DOC-FIX]"
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!WARNING]
|
||||
> Before submitting a PR, please make sure that:
|
||||
> - A maintainer has triaged this issue and applied the `ready` label
|
||||
> - This issue has no assignee
|
||||
> - No duplicate PR exists
|
||||
>
|
||||
> PRs not meeting these requirements may be automatically closed.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for submitting an issue. Please refer to our [issue policy](https://www.github.com/mlflow/mlflow/blob/master/ISSUE_POLICY.md) for information on what types of issues we address.
|
||||
|
||||
**Is this the right repository?**
|
||||
- ✅ **Documentation pages** under https://mlflow.org/docs (API reference, tutorials, how-to guides) — you're in the right place!
|
||||
- ❌ **Main website content** (blog posts, marketing pages, other site content) — please file at https://github.com/mlflow/mlflow-website/issues instead
|
||||
|
||||
**Important note about documentation branches:**
|
||||
The production documentation at https://mlflow.org/docs/latest/ is built from the **release branch** (e.g., `branch-3.7`), not `master`. If you notice a discrepancy between the live docs and the source code on `master`, the fix may already exist on `master`. You can verify this by checking the preview site at https://dev--mlflow-docs-preview.netlify.app/docs/latest which is built from `master`.
|
||||
|
||||
**Please fill in this documentation issue template to ensure a timely and thorough response.**
|
||||
- type: dropdown
|
||||
id: contribution
|
||||
attributes:
|
||||
label: Willingness to contribute
|
||||
description: The MLflow Community encourages documentation fix contributions. Would you or another member of your organization be willing to contribute a fix for this documentation issue to the MLflow code base?
|
||||
options:
|
||||
- Yes. I can contribute a documentation fix independently.
|
||||
- Yes. I would be willing to contribute a document fix with guidance from the MLflow community.
|
||||
- No. I cannot contribute a documentation fix at this time.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: URL(s) with the issue
|
||||
description: |
|
||||
Please provide a link to the documentation entry in question.
|
||||
Note: This repo is for https://mlflow.org/docs pages only. For other website content, use https://github.com/mlflow/mlflow-website/issues.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description of proposal (what needs changing)
|
||||
description: |
|
||||
Provide a clear description. Why is the proposed documentation better?
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,105 @@
|
||||
name: Feature Request
|
||||
description: Use this template for feature and enhancement proposals.
|
||||
labels: "enhancement"
|
||||
title: "[FR]"
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!WARNING]
|
||||
> Before submitting a PR, please make sure that:
|
||||
> - A maintainer has triaged this issue and applied the `ready` label
|
||||
> - This issue has no assignee
|
||||
> - No duplicate PR exists
|
||||
>
|
||||
> PRs not meeting these requirements may be automatically closed.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for submitting a feature request. **Before proceeding, please review MLflow's [Issue Policy for feature requests](https://www.github.com/mlflow/mlflow/blob/master/ISSUE_POLICY.md#feature-requests) and the [MLflow Contributing Guide](https://github.com/mlflow/mlflow/blob/master/CONTRIBUTING.md)**.
|
||||
**Please fill in this feature request template to ensure a timely and thorough response.**
|
||||
- type: dropdown
|
||||
id: contribution
|
||||
attributes:
|
||||
label: Willingness to contribute
|
||||
description: The MLflow Community encourages new feature contributions. Would you or another member of your organization be willing to contribute an implementation of this feature (either as an MLflow Plugin or an enhancement to the MLflow code base)?
|
||||
options:
|
||||
- Yes. I can contribute this feature independently.
|
||||
- Yes. I would be willing to contribute this feature with guidance from the MLflow community.
|
||||
- No. I cannot contribute this feature at this time.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Proposal Summary
|
||||
description: |
|
||||
In a few sentences, provide a clear, high-level description of the feature request
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Motivation
|
||||
description: |
|
||||
- What is the use case for this feature?
|
||||
- Why is this use case valuable to support for MLflow users in general?
|
||||
- Why is this use case valuable to support for your project(s) or organization?
|
||||
- Why is it currently difficult to achieve this use case? (please be as specific as possible about why related MLflow features and components are insufficient)
|
||||
value: |
|
||||
> #### What is the use case for this feature?
|
||||
|
||||
> #### Why is this use case valuable to support for MLflow users in general?
|
||||
|
||||
> #### Why is this use case valuable to support for your project(s) or organization?
|
||||
|
||||
> #### Why is it currently difficult to achieve this use case?
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Details
|
||||
description: |
|
||||
Use this section to include any additional information about the feature. If you have a proposal for how to implement this feature, please include it here. For implementation guidelines, please refer to the [Contributing Guide](https://github.com/mlflow/mlflow/blob/master/CONTRIBUTING.md#contribution-guidelines).
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: domain
|
||||
attributes:
|
||||
label: What machine learning domain(s) is this feature request about?
|
||||
description: Please choose one or more domains below.
|
||||
options:
|
||||
- label: "`domain/genai`: LLMs, Agents, and other GenAI-related use cases"
|
||||
required: false
|
||||
- label: "`domain/classical-ml`: Traditional machine learning, such as linear regression."
|
||||
required: false
|
||||
- label: "`domain/deep-learning`: Deep learning and neural networks."
|
||||
required: false
|
||||
- label: "`domain/platform`: MLflow platform foundation, not specific to a particular machine learning domain."
|
||||
|
||||
- type: checkboxes
|
||||
id: component
|
||||
attributes:
|
||||
label: What area(s) of MLflow is this feature request about?
|
||||
description: Please choose one or more components below.
|
||||
options:
|
||||
- label: "`area/tracking`: Tracking Service, tracking client APIs, autologging"
|
||||
required: false
|
||||
- label: "`area/model-registry`: Model Registry service, APIs, and the fluent client calls for Model Registry"
|
||||
required: false
|
||||
- label: "`area/scoring`: MLflow model serving, deployment tools, Spark UDFs"
|
||||
required: false
|
||||
- label: "`area/evaluation`: MLflow model evaluation features, evaluation metrics, and evaluation workflows"
|
||||
required: false
|
||||
- label: "`area/prompt`: MLflow prompt engineering features, prompt templates, and prompt management"
|
||||
required: false
|
||||
- label: "`area/tracing`: MLflow Tracing features, tracing APIs, and LLM tracing functionality"
|
||||
required: false
|
||||
- label: "`area/gateway`: MLflow AI Gateway client APIs, server, and third-party integrations"
|
||||
required: false
|
||||
- label: "`area/projects`: MLproject format, project running backends"
|
||||
required: false
|
||||
- label: "`area/uiux`: Front-end, user experience, plotting"
|
||||
required: false
|
||||
- label: "`area/docs`: MLflow documentation pages"
|
||||
required: false
|
||||
@@ -0,0 +1,25 @@
|
||||
name: Good First Issue
|
||||
description: "[Maintainer only] Use this template for issues that are good for first time contributors."
|
||||
labels: "good first issue"
|
||||
|
||||
body:
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Summary
|
||||
description: |
|
||||
A summary of the issue.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Notes
|
||||
value: |
|
||||
- Make sure to open a PR from a **non-master** branch.
|
||||
- Sign off the commit using the `-s` flag when making a commit:
|
||||
|
||||
```sh
|
||||
git commit -s -m "..."
|
||||
# ^^ make sure to use this
|
||||
```
|
||||
|
||||
- Include `#{issue_number}` (e.g. `#123`) in the PR description when opening a PR.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 787 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
@@ -0,0 +1,62 @@
|
||||
name: Installation Issues
|
||||
description: Use this template for reporting bugs encountered while installing MLflow.
|
||||
labels: "bug"
|
||||
title: "[SETUP-BUG]"
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!WARNING]
|
||||
> Before submitting a PR, please make sure that:
|
||||
> - A maintainer has triaged this issue and applied the `ready` label
|
||||
> - This issue has no assignee
|
||||
> - No duplicate PR exists
|
||||
>
|
||||
> PRs not meeting these requirements may be automatically closed.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for submitting an issue. Please refer to our [issue policy](https://www.github.com/mlflow/mlflow/blob/master/ISSUE_POLICY.md) for information on what types of issues we address.
|
||||
**Please fill in this installation issue template to ensure a timely and thorough response.**
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: System information
|
||||
description: |
|
||||
Describe the system where you encountered the installation issue.
|
||||
value: |
|
||||
- **OS Platform and Distribution (e.g., Linux Ubuntu 16.04)**:
|
||||
- **MLflow installed from (source or binary)**:
|
||||
- **MLflow version (run ``mlflow --version``)**:
|
||||
- **Python version**:
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Code to reproduce issue
|
||||
description: |
|
||||
Provide a reproducible test case that is the bare minimum necessary to generate the problem.
|
||||
placeholder: |
|
||||
```bash
|
||||
pip install mlflow=x.y.z
|
||||
```
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the problem
|
||||
description: |
|
||||
Provide the exact sequence of commands / steps that you executed before running into the problem.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Other info / logs
|
||||
description: |
|
||||
Include any logs or source code that would be helpful to diagnose the problem. If including tracebacks, please include the full traceback. Large logs and files should be attached.
|
||||
placeholder: |
|
||||
```
|
||||
ERROR: Could not find a version that satisfies the requirement mlflow==x.y.z
|
||||
```
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,122 @@
|
||||
name: UI Bug Report
|
||||
description: Create a report to help us reproduce and correct the UI bug
|
||||
labels: ["bug", "area/uiux"]
|
||||
title: "[BUG]"
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> [!WARNING]
|
||||
> Before submitting a PR, please make sure that:
|
||||
> - A maintainer has triaged this issue and applied the `ready` label
|
||||
> - This issue has no assignee
|
||||
> - No duplicate PR exists
|
||||
>
|
||||
> PRs not meeting these requirements may be automatically closed.
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for submitting an issue. Please refer to our [issue policy](https://www.github.com/mlflow/mlflow/blob/master/ISSUE_POLICY.md) for additional information about bug reports. For help with debugging your code, please refer to [Stack Overflow](https://stackoverflow.com/questions/tagged/mlflow).
|
||||
#### Please fill in this UI bug report template to ensure a timely and thorough response.
|
||||
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: MLflow version
|
||||
description: MLflow version (run `mlflow --version`) or commit SHA if you have MLflow installed from source (run `pip freeze | grep mlflow`).
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: System information
|
||||
description: |
|
||||
Describe the system where you encountered the bug.
|
||||
value: |
|
||||
- **OS Platform and Distribution (e.g., Linux Ubuntu 16.04)**:
|
||||
- **Python version**:
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Describe the problem
|
||||
description: |
|
||||
Describe the problem clearly here. Include descriptions of the expected behavior and the actual behavior.
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Steps to reproduce the bug
|
||||
description: |
|
||||
**Record steps to reproduce the bug as a video or GIF** (to eliminate ambiguity) and attach it here.
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Code to generate data required to reproduce the bug
|
||||
description: |
|
||||
Please provide code to generate data required to reproduce the bug.
|
||||
placeholder: |
|
||||
```python
|
||||
import mlflow
|
||||
|
||||
with mlflow.start_run():
|
||||
mlflow.log_param("p", 0)
|
||||
mlflow.log_metric("m", 1)
|
||||
```
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Is the console panel in DevTools showing errors relevant to the bug? Type 'N/A' if not applicable.
|
||||
description: |
|
||||
If the console panel in your browser's DevTools is showing errors (displayed in red) relevant to the bug as shown in the screenshot below, please provide them as text (preferred) or a screenshot.
|
||||
|
||||
#### Instructions on how to use DevTools:
|
||||
- Chrome: [Chrome DevTools](https://developer.chrome.com/docs/devtools/)
|
||||
- Firefox: [Firefox DevTools User Docs](https://firefox-source-docs.mozilla.org/devtools-user/index.html)
|
||||
- Edge: [Overview of DevTools](https://docs.microsoft.com/en-us/microsoft-edge/devtools-guide-chromium/overview)
|
||||
|
||||

|
||||
<p align="center">Console panel on Chrome</P>
|
||||
placeholder: |
|
||||
```
|
||||
TypeError: Cannot read properties of undefined (reading 'x')
|
||||
at n.value (HomeView.js:33:22)
|
||||
at za (react-dom.production.min.js:187:188)
|
||||
at Za (react-dom.production.min.js:186:173)
|
||||
at qs (react-dom.production.min.js:269:427)
|
||||
at Tl (react-dom.production.min.js:250:347)
|
||||
```
|
||||
|
||||
- type: textarea
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Does the network panel in DevTools contain failed requests relevant to the bug? Type 'N/A' if not applicable.
|
||||
description: |
|
||||
If the network panel in your browser's DevTools contain failed requests (displayed in red) relevant to the bug as shown in the screenshot below, please provide them as text (preferred) or a screenshot.
|
||||
|
||||

|
||||
<p align="center">Network panel on Chrome</P>
|
||||
placeholder: |
|
||||
```
|
||||
# Request URL
|
||||
http://localhost:5000/ajax-api/2.0/preview/mlflow/xxx/yyy
|
||||
|
||||
# Status Code
|
||||
400
|
||||
|
||||
# Payload
|
||||
{"a": 0}
|
||||
|
||||
# Response
|
||||
{"b": 1}
|
||||
```
|
||||
@@ -0,0 +1,11 @@
|
||||
name: "cache-hf"
|
||||
description: "Cache HuggingFace Hub artifacts to avoid 429s in CI"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: ~/.cache/huggingface/hub/datasets--*
|
||||
key: ${{ runner.os }}-hf-datasets-${{ hashFiles('tests/data/test_huggingface_dataset_and_source.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-hf-datasets
|
||||
@@ -0,0 +1,8 @@
|
||||
name: "check-component-ids"
|
||||
description: "Verify that all componentIds in the MLflow UI are registered in the componentId registry and vice versa."
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: ./.github/actions/setup-node
|
||||
- run: node ${GITHUB_ACTION_PATH}/index.js
|
||||
shell: bash
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
const { extractComponentIdsFromSource } = require("./utils");
|
||||
|
||||
const registry = require("./componentId-registry");
|
||||
|
||||
// --- Main ---
|
||||
const codeIds = extractComponentIdsFromSource(__dirname);
|
||||
const registryKeys = new Set(Object.keys(registry));
|
||||
|
||||
// Check 1: componentIds in code but not in registry
|
||||
const unregistered = [...codeIds].filter((id) => !registryKeys.has(id)).sort();
|
||||
|
||||
// Check 2: componentIds in registry but not in code (stale)
|
||||
const stale = [...registryKeys].filter((id) => !codeIds.has(id)).sort();
|
||||
|
||||
let failed = false;
|
||||
|
||||
if (unregistered.length > 0) {
|
||||
failed = true;
|
||||
console.error(
|
||||
`\n❌ Found ${unregistered.length} componentId(s) in code but NOT in the registry:\n`
|
||||
);
|
||||
for (const id of unregistered) {
|
||||
console.error(` + ${id}`);
|
||||
}
|
||||
console.error("\nAdd these to .github/actions/check-component-ids/componentId-registry.js");
|
||||
}
|
||||
|
||||
if (stale.length > 0) {
|
||||
failed = true;
|
||||
console.error(`\n❌ Found ${stale.length} stale componentId(s) in registry but NOT in code:\n`);
|
||||
for (const id of stale) {
|
||||
console.error(` - ${id}`);
|
||||
}
|
||||
console.error("\nRemove these from .github/actions/check-component-ids/componentId-registry.js");
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`✅ componentId registry is in sync. ${registryKeys.size} entries verified.`);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Regenerates the componentId registry from source code.
|
||||
*
|
||||
* Usage (from repo root):
|
||||
* node .github/actions/check-component-ids/regenerate.js
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { extractComponentIdsFromSource } = require("./utils");
|
||||
|
||||
const codeIds = extractComponentIdsFromSource(__dirname);
|
||||
const sorted = [...codeIds].sort();
|
||||
|
||||
// Group by prefix for readability
|
||||
const groups = {};
|
||||
for (const id of sorted) {
|
||||
let prefix;
|
||||
if (id.startsWith("codegen_")) {
|
||||
prefix = "Codegen (auto-generated)";
|
||||
} else if (id.startsWith("mlflow.")) {
|
||||
const parts = id.split(".");
|
||||
prefix = parts[0] + "." + parts[1];
|
||||
} else if (id.startsWith("shared.")) {
|
||||
const parts = id.split(".");
|
||||
prefix = parts[0] + "." + parts[1];
|
||||
} else {
|
||||
prefix = "Other";
|
||||
}
|
||||
if (!groups[prefix]) groups[prefix] = [];
|
||||
groups[prefix].push(id);
|
||||
}
|
||||
|
||||
// Load existing registry to preserve descriptions
|
||||
let existingDescriptions = {};
|
||||
try {
|
||||
existingDescriptions = require("./componentId-registry");
|
||||
} catch {
|
||||
// First run or broken registry — start fresh
|
||||
}
|
||||
|
||||
let output = `/**
|
||||
* Curated registry of all componentIds used in the MLflow UI.
|
||||
*
|
||||
* Every static componentId string literal in non-test source files must
|
||||
* have an entry here. The CI job \`check-component-ids\` verifies this
|
||||
* bidirectionally: code IDs must be in the registry, and registry
|
||||
* entries must exist in code.
|
||||
*
|
||||
* Format: key = componentId string, value = optional description of the
|
||||
* component (blank by default, especially for generated entries)
|
||||
*/
|
||||
module.exports = {\n`;
|
||||
|
||||
for (const gk of Object.keys(groups).sort()) {
|
||||
output += ` // -- ${gk} --\n`;
|
||||
for (const id of groups[gk]) {
|
||||
const escaped = id.replace(/"/g, '\\"');
|
||||
const desc = (existingDescriptions[id] || "").replace(/"/g, '\\"');
|
||||
output += ` "${escaped}": "${desc}",\n`;
|
||||
}
|
||||
output += "\n";
|
||||
}
|
||||
output += "};\n";
|
||||
|
||||
const outPath = path.join(__dirname, "componentId-registry.js");
|
||||
fs.writeFileSync(outPath, output);
|
||||
console.log(`✅ Registry regenerated with ${sorted.length} entries at ${outPath}`);
|
||||
@@ -0,0 +1,66 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const EXTENSIONS = [".js", ".jsx", ".ts", ".tsx"];
|
||||
// Skip test files — they don't need registered componentIds
|
||||
const TEST_PATTERN = /\.test\.[jt]sx?$/;
|
||||
|
||||
const EXTRACT_PATTERNS = [
|
||||
/(?:componentId|data-component-id)=["']([^"']+)["']/g,
|
||||
/componentId:\s*["']([^"']+)["']/g,
|
||||
// Match static strings inside JSX expressions like componentId={"value"},
|
||||
// componentId={cond ?? "fallback"}, componentId={cond ? "a" : "b"}, etc.
|
||||
// Uses [^\n}]* to avoid matching across lines.
|
||||
/componentId=\{[^\n}]*["']([^"'\n`]+)["'][^\n}]*\}/g,
|
||||
];
|
||||
|
||||
function findFiles(dir) {
|
||||
const results = [];
|
||||
function walk(d) {
|
||||
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
||||
const full = path.join(d, entry.name);
|
||||
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
|
||||
walk(full);
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
EXTENSIONS.some((ext) => full.endsWith(ext)) &&
|
||||
!TEST_PATTERN.test(full)
|
||||
) {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(dir);
|
||||
return results;
|
||||
}
|
||||
|
||||
function extractComponentIds(files) {
|
||||
const ids = new Set();
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file, "utf8");
|
||||
for (const pat of EXTRACT_PATTERNS) {
|
||||
pat.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = pat.exec(content)) !== null) {
|
||||
ids.add(m[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all static componentIds from the MLflow UI source directory.
|
||||
* @param {string} actionDir - path to this action's directory (used to resolve the repo root)
|
||||
* @returns {Set<string>} set of componentId strings found in source
|
||||
*/
|
||||
function extractComponentIdsFromSource(actionDir) {
|
||||
const srcDir = path.resolve(
|
||||
process.env.GITHUB_WORKSPACE || path.join(actionDir, "../../.."),
|
||||
"mlflow/server/js/src"
|
||||
);
|
||||
const files = findFiles(srcDir);
|
||||
return extractComponentIds(files);
|
||||
}
|
||||
|
||||
module.exports = { extractComponentIdsFromSource };
|
||||
@@ -0,0 +1,9 @@
|
||||
name: "free-disk-space"
|
||||
description: "free disk space"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- shell: bash
|
||||
run: |
|
||||
# Run in background to save time
|
||||
sudo rm -rf /usr/local/lib/android &
|
||||
@@ -0,0 +1,27 @@
|
||||
name: "setup-java"
|
||||
description: "Set up Java"
|
||||
inputs:
|
||||
java-version:
|
||||
description: "java-version"
|
||||
default: "17"
|
||||
required: false
|
||||
|
||||
distribution:
|
||||
description: "distribution"
|
||||
default: temurin
|
||||
required: false
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: ${{ inputs.java-version }}
|
||||
distribution: ${{ inputs.distribution }}
|
||||
|
||||
- name: Set MLFLOW_DOCKER_OPENJDK_VERSION
|
||||
shell: bash
|
||||
env:
|
||||
JAVA_VERSION: ${{ inputs.java-version }}
|
||||
run: |
|
||||
echo "MLFLOW_DOCKER_OPENJDK_VERSION=$JAVA_VERSION" >> $GITHUB_ENV
|
||||
@@ -0,0 +1,19 @@
|
||||
name: "setup-node"
|
||||
description: "Set up Node"
|
||||
inputs:
|
||||
node-version:
|
||||
description: "Node version to use."
|
||||
default: "24"
|
||||
required: false
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
- run: |
|
||||
# allowScripts opt-in install-script policy requires npm >= 11.16.0
|
||||
# (https://github.com/npm/cli/pull/9360)
|
||||
npm install -g npm@"^11.16.0"
|
||||
shell: bash
|
||||
@@ -0,0 +1,62 @@
|
||||
name: "setup-pyenv"
|
||||
description: "Setup pyenv"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
########## Ubuntu ##########
|
||||
- name: Install python build tools
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
# Ref: https://github.com/pyenv/pyenv/wiki#suggested-build-environment
|
||||
# Note: llvm is optional (required only for building PyPy/clang)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get purge -y man-db || true
|
||||
sudo apt-get install -y --no-install-recommends make build-essential libssl-dev zlib1g-dev \
|
||||
libbz2-dev libreadline-dev libsqlite3-dev wget curl \
|
||||
libncursesw5-dev xz-utils libffi-dev liblzma-dev
|
||||
- name: Install pyenv
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
# Pin to pyenv v2.6.25 by tag + SHA verification for reproducible builds
|
||||
git clone --branch v2.6.25 --depth 1 https://github.com/pyenv/pyenv.git "$HOME/.pyenv"
|
||||
actual_sha=$(git -C "$HOME/.pyenv" rev-parse HEAD)
|
||||
expected_sha="aa2e8b82605b8ba085dd15f7a31fe1990fabdcfa"
|
||||
if [ "$actual_sha" != "$expected_sha" ]; then
|
||||
echo "::error::pyenv SHA mismatch: expected $expected_sha, got $actual_sha"
|
||||
exit 1
|
||||
fi
|
||||
- name: Setup environment variables
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
PYENV_ROOT="$HOME/.pyenv"
|
||||
PYENV_BIN="$PYENV_ROOT/bin"
|
||||
echo "$PYENV_BIN" >> $GITHUB_PATH
|
||||
echo "PYENV_ROOT=$PYENV_ROOT" >> $GITHUB_ENV
|
||||
- name: Check pyenv version
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
pyenv --version
|
||||
|
||||
########## Windows ##########
|
||||
- name: Install pyenv
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
uv pip install --system pyenv-win --target $HOME\\.pyenv
|
||||
- name: Setup environment variables
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "PYENV=$USERPROFILE\.pyenv\pyenv-win\\" >> $GITHUB_ENV
|
||||
echo "PYENV_ROOT=$USERPROFILE\.pyenv\pyenv-win\\" >> $GITHUB_ENV
|
||||
echo "PYENV_HOME=$USERPROFILE\.pyenv\pyenv-win\\" >> $GITHUB_ENV
|
||||
echo "$USERPROFILE\.pyenv\pyenv-win\\bin\\" >> $GITHUB_PATH
|
||||
- name: Check pyenv version
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: |
|
||||
pyenv --version
|
||||
@@ -0,0 +1,79 @@
|
||||
name: "setup-python"
|
||||
description: "Ensures to install a python version that's available on Anaconda"
|
||||
inputs:
|
||||
python-version:
|
||||
description: "The python version to install. If unspecified, install the minimum python version mlflow supports."
|
||||
required: false
|
||||
pin-micro-version:
|
||||
description: "Whether to pin to a specific micro version for Anaconda compatibility. Set to false for workflows that don't need conda/pyenv to hit the runner's pre-installed Python cache and avoid a ~9s download."
|
||||
required: false
|
||||
default: "true"
|
||||
outputs:
|
||||
python-version:
|
||||
description: "The installed python version"
|
||||
value: ${{ steps.get-python-version.outputs.version }}
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: get-python-version
|
||||
id: get-python-version
|
||||
shell: bash
|
||||
# We used to use `conda search python=3.x` to dynamically fetch the latest available version
|
||||
# in 3.x on Anaconda, but it turned out `conda search` is very slow (takes 40 ~ 50 seconds).
|
||||
# This overhead sums up to a significant amount of delay in the cross version tests
|
||||
# where we trigger more than 100 GitHub Actions runs.
|
||||
env:
|
||||
PYTHON_VERSION_INPUT: ${{ inputs.python-version }}
|
||||
PIN_MICRO_VERSION: ${{ inputs.pin-micro-version }}
|
||||
run: |
|
||||
python_version="$PYTHON_VERSION_INPUT"
|
||||
if [ -z "$python_version" ]; then
|
||||
python_version=$(cat .python-version)
|
||||
fi
|
||||
if [[ "$PIN_MICRO_VERSION" == "true" ]]; then
|
||||
if [[ "$python_version" == "3.10" ]]; then
|
||||
if [ "$RUNNER_OS" == "Linux" ]; then
|
||||
python_version="3.10.20"
|
||||
else
|
||||
python_version="3.10.11"
|
||||
fi
|
||||
elif [[ "$python_version" == "3.11" ]]; then
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
python_version="3.11.9"
|
||||
else
|
||||
python_version="3.11.15"
|
||||
fi
|
||||
elif [[ "$python_version" == "3.12" ]]; then
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
python_version="3.12.10"
|
||||
else
|
||||
python_version="3.12.13"
|
||||
fi
|
||||
else
|
||||
echo "Invalid python version: '$python_version'. Must be '3.10', '3.11', or '3.12'."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "version=$python_version" >> $GITHUB_OUTPUT
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: ${{ steps.get-python-version.outputs.version }}
|
||||
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
version: "0.11.14"
|
||||
# Caching disabled to avoid cache-poisoning exposure across CI workflows.
|
||||
enable-cache: false
|
||||
- run: |
|
||||
# The default `first-index` strategy is too strict. Use `unsafe-first-match` instead.
|
||||
# https://docs.astral.sh/uv/configuration/environment/#uv_index_strategy
|
||||
echo "UV_INDEX_STRATEGY=unsafe-first-match" >> $GITHUB_ENV
|
||||
# Disable progress bars and spinners to reduce CI log clutter
|
||||
# https://docs.astral.sh/uv/reference/environment/#uv_no_progress
|
||||
echo "UV_NO_PROGRESS=1" >> $GITHUB_ENV
|
||||
# Exclude packages newer than 7 days to guard against supply chain attacks
|
||||
# https://docs.astral.sh/uv/reference/environment/#uv_exclude_newer
|
||||
echo "UV_EXCLUDE_NEWER=P7D" >> $GITHUB_ENV
|
||||
# Disable Hugging Face download progress bars to reduce CI log clutter
|
||||
# https://huggingface.co/docs/huggingface_hub/package_reference/environment_variables#hfhubdisableprogressbars
|
||||
echo "HF_HUB_DISABLE_PROGRESS_BARS=1" >> $GITHUB_ENV
|
||||
shell: bash
|
||||
@@ -0,0 +1,21 @@
|
||||
name: "show-versions"
|
||||
description: "Show python package versions sorted by release date"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- shell: bash
|
||||
run: |
|
||||
# Activate the virtual environment if .venv exists
|
||||
if [ -d .venv ]; then
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
source .venv/Scripts/activate
|
||||
else
|
||||
source .venv/bin/activate
|
||||
fi
|
||||
fi
|
||||
pip --disable-pip-version-check install ./dev/pypi > /dev/null
|
||||
echo ">>> package versions"
|
||||
status=0
|
||||
python dev/show_package_release_dates.py || status=$?
|
||||
echo "<<< package versions"
|
||||
exit $status
|
||||
@@ -0,0 +1,6 @@
|
||||
name: "untracked"
|
||||
description: "Detect untracked files"
|
||||
runs:
|
||||
using: "node24"
|
||||
main: "index.js"
|
||||
post: "post.js"
|
||||
@@ -0,0 +1 @@
|
||||
// Does nothing
|
||||
@@ -0,0 +1,20 @@
|
||||
const { exec } = require("child_process");
|
||||
|
||||
exec("git ls-files --others --exclude-standard", (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
console.error(`An error occurred: ${error}`);
|
||||
process.exit(error.code || 1);
|
||||
}
|
||||
|
||||
const untrackedFiles = stdout.trim();
|
||||
|
||||
if (untrackedFiles === "") {
|
||||
console.log("No untracked files found.");
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log("Untracked files found:");
|
||||
console.log(untrackedFiles);
|
||||
console.log("Consider adding them to .gitignore.");
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
name: "update-requirements"
|
||||
description: "Update requirements YAML specifications and re-generate requirements text files"
|
||||
|
||||
outputs:
|
||||
updated:
|
||||
description: "Indicates whether the requirements have been updated"
|
||||
value: ${{ steps.update-requirements.outputs.updated }}
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Update requirements
|
||||
id: update-requirements
|
||||
shell: bash --noprofile --norc -exo pipefail {0}
|
||||
run: |
|
||||
python bin/install.py
|
||||
python dev/update_requirements.py --requirements-yaml-location requirements
|
||||
if [ -z "$(git status --porcelain)" ]; then
|
||||
echo "updated=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
python dev/pyproject.py
|
||||
git diff --color=always
|
||||
echo "updated=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
applyTo: "**"
|
||||
excludeAgent: ["coding-agent"]
|
||||
---
|
||||
|
||||
# Code Review Instructions
|
||||
|
||||
## Knowledge Cutoff
|
||||
|
||||
Your training data has a cutoff. Treat anything you don't recognize as **new, not wrong**. Here are things you might flag as incorrect but are actually valid:
|
||||
|
||||
- Model names (e.g., `gpt-5`)
|
||||
- GitHub runner types (e.g., `ubuntu-slim`)
|
||||
|
||||
## Comment Format (MANDATORY)
|
||||
|
||||
Every comment MUST use this exact format: `<emoji> **<severity>:** <description>`
|
||||
|
||||
| Severity | Emoji |
|
||||
| -------- | ----- |
|
||||
| CRITICAL | 🔴 |
|
||||
| MODERATE | 🟡 |
|
||||
| NIT | 🟢 |
|
||||
|
||||
Examples:
|
||||
|
||||
- 🔴 **CRITICAL:** User input is passed directly into the SQL query without parameterization — SQL injection risk. Use a parameterized query instead.
|
||||
- 🟡 **MODERATE:** This loops over each item and issues a separate query — N+1 problem. Use a single batch query or a join.
|
||||
- 🟢 **NIT:** This nested `if/elif/else` is hard to follow. Consider using early returns to flatten the structure.
|
||||
|
||||
## Do NOT Comment On
|
||||
|
||||
- Future dates, version numbers, model names, or runner types — your knowledge cutoff makes these unreliable
|
||||
- Discrepancies between PR description and code — focus on the code
|
||||
- Naming style preferences — only flag actively misleading names
|
||||
- Hypothetical or unlikely edge cases — if you'd write "while unlikely", "could potentially", or "edge case where", skip it. Only flag issues that realistically occur in practice.
|
||||
- Hardcoded values or magic numbers — do not suggest extracting constants for one-off values
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
applyTo: ".github/workflows/**/*.yml"
|
||||
---
|
||||
|
||||
# GitHub Actions Code Review Instructions
|
||||
|
||||
For workflow style conventions, see [.claude/rules/github-actions.md](../../.claude/rules/github-actions.md).
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
applyTo: "**/*.py"
|
||||
---
|
||||
|
||||
# Python Code Review Instructions
|
||||
|
||||
For style conventions and code examples, see [.claude/rules/python.md](../../.claude/rules/python.md).
|
||||
@@ -0,0 +1,467 @@
|
||||
# regal ignore:directory-package-mismatch
|
||||
package mlflow
|
||||
|
||||
import rego.v1
|
||||
|
||||
deny_jobs_without_permissions contains msg if {
|
||||
jobs := jobs_without_permissions(input.jobs)
|
||||
count(jobs) > 0
|
||||
msg := sprintf(
|
||||
"The following jobs are missing permissions: %s",
|
||||
[concat(", ", jobs)],
|
||||
)
|
||||
}
|
||||
|
||||
deny_top_level_permissions contains msg if {
|
||||
# Workflow files only (composite actions have 'runs')
|
||||
input.jobs
|
||||
not input.permissions
|
||||
msg := concat("", [
|
||||
"Workflow must set top-level 'permissions: {}' to deny all by default. ",
|
||||
"Grant least-privilege permissions per job.",
|
||||
])
|
||||
}
|
||||
|
||||
deny_top_level_permissions contains msg if {
|
||||
input.jobs
|
||||
input.permissions != {}
|
||||
msg := "Top-level 'permissions' must be empty ({}). Grant least-privilege permissions per job instead."
|
||||
}
|
||||
|
||||
deny_unsafe_checkout contains msg if {
|
||||
# The "on" key gets transformed by conftest into "true" due to some legacy
|
||||
# YAML standards, see https://stackoverflow.com/q/42283732/2148786 - so
|
||||
# "on.push" becomes "true.push" which is why below statements use "true"
|
||||
# instead of "on".
|
||||
input["true"].pull_request_target
|
||||
not safe_pull_request_target_workflow
|
||||
some job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/checkout@")
|
||||
step["with"].ref
|
||||
msg := concat("", [
|
||||
"Explicit checkout in a pull_request_target workflow is unsafe. ",
|
||||
"See https://securitylab.github.com/resources/github-actions-preventing-pwn-requests for more information.",
|
||||
])
|
||||
}
|
||||
|
||||
# Workflows that are safe to use pull_request_target with explicit checkout
|
||||
# because they restrict execution to trusted authors via author_association.
|
||||
safe_pull_request_target_workflow if {
|
||||
input.name == "UI Preview"
|
||||
}
|
||||
|
||||
deny_create_app_token_without_permissions contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/create-github-app-token@")
|
||||
not step_has_app_token_permissions(step)
|
||||
msg := sprintf(
|
||||
concat("", [
|
||||
"actions/create-github-app-token in job '%s' must explicitly request permissions ",
|
||||
"via 'permission-<name>: <level>' inputs (e.g., permission-contents: write) for ",
|
||||
"least-privilege access. See ",
|
||||
"https://github.com/actions/create-github-app-token#create-a-token-with-specific-permissions",
|
||||
]),
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
deny_create_app_token_with_app_id contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/create-github-app-token@")
|
||||
step["with"]["app-id"]
|
||||
msg := sprintf(
|
||||
"actions/create-github-app-token in job '%s' uses deprecated 'app-id'. Use 'client-id' instead.",
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
step_has_app_token_permissions(step) if {
|
||||
some key, _ in step["with"]
|
||||
startswith(key, "permission-")
|
||||
}
|
||||
|
||||
deny_unnecessary_github_token contains msg if {
|
||||
some job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/github-script@")
|
||||
regex.match(`\$\{\{\s*(secrets\.GITHUB_TOKEN|github\.token)\s*\}\}`, step["with"]["github-token"])
|
||||
msg := "Unnecessary use of github-token for actions/github-script."
|
||||
}
|
||||
|
||||
deny_github_token_env_var contains msg if {
|
||||
some job in input.jobs
|
||||
some step in job.steps
|
||||
step.env.GITHUB_TOKEN
|
||||
msg := "Use GH_TOKEN instead of GITHUB_TOKEN for environment variable names."
|
||||
}
|
||||
|
||||
deny_github_token_env_var contains msg if {
|
||||
some job in input.jobs
|
||||
job.env.GITHUB_TOKEN
|
||||
msg := "Use GH_TOKEN instead of GITHUB_TOKEN for environment variable names."
|
||||
}
|
||||
|
||||
deny_github_token_shorthand contains msg if {
|
||||
some job in input.jobs
|
||||
some step in job.steps
|
||||
some key, value in step["with"]
|
||||
contains_github_token(value)
|
||||
msg := sprintf(
|
||||
"Use secrets.GITHUB_TOKEN instead of github.token for consistency (found in step with.%s).",
|
||||
[key],
|
||||
)
|
||||
}
|
||||
|
||||
deny_github_token_shorthand contains msg if {
|
||||
some job in input.jobs
|
||||
some step in job.steps
|
||||
some key, value in step.env
|
||||
contains_github_token(value)
|
||||
msg := sprintf(
|
||||
"Use secrets.GITHUB_TOKEN instead of github.token for consistency (found in step env.%s).",
|
||||
[key],
|
||||
)
|
||||
}
|
||||
|
||||
deny_github_token_shorthand contains msg if {
|
||||
some job in input.jobs
|
||||
some key, value in job.env
|
||||
contains_github_token(value)
|
||||
msg := sprintf(
|
||||
"Use secrets.GITHUB_TOKEN instead of github.token for consistency (found in job env.%s).",
|
||||
[key],
|
||||
)
|
||||
}
|
||||
|
||||
deny_github_token_shorthand contains msg if {
|
||||
some key, value in input.env
|
||||
contains_github_token(value)
|
||||
msg := sprintf(
|
||||
"Use secrets.GITHUB_TOKEN instead of github.token for consistency (found in top-level env.%s).",
|
||||
[key],
|
||||
)
|
||||
}
|
||||
|
||||
deny_jobs_without_timeout contains msg if {
|
||||
jobs := jobs_without_timeout(input.jobs)
|
||||
count(jobs) > 0
|
||||
msg := sprintf(
|
||||
"The following jobs are missing timeout-minutes: %s",
|
||||
[concat(", ", jobs)],
|
||||
)
|
||||
}
|
||||
|
||||
deny_ubuntu_slim_long_timeout contains msg if {
|
||||
jobs := ubuntu_slim_jobs_with_long_timeout(input.jobs)
|
||||
count(jobs) > 0
|
||||
msg := sprintf(
|
||||
"The following ubuntu-slim jobs have timeout-minutes > 15: %s. ubuntu-slim has a 15-minute timeout limit.",
|
||||
[concat(", ", jobs)],
|
||||
)
|
||||
}
|
||||
|
||||
deny_unpinned_actions contains msg if {
|
||||
actions := unpinned_actions(input)
|
||||
count(actions) > 0
|
||||
msg := sprintf(
|
||||
concat("", [
|
||||
"The following actions are not pinned by full commit SHA: %s. ",
|
||||
"Use the full commit SHA instead ",
|
||||
"(e.g., actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683).",
|
||||
]),
|
||||
[concat(", ", actions)],
|
||||
)
|
||||
}
|
||||
|
||||
deny_missing_shell_defaults contains msg if {
|
||||
# Only check workflow files (not composite actions)
|
||||
# Composite actions have 'runs' instead of 'jobs'
|
||||
input.jobs
|
||||
not input.defaults.run.shell
|
||||
msg := "Workflow must have 'defaults.run.shell: bash' to enable pipefail by default"
|
||||
}
|
||||
|
||||
deny_wrong_shell_defaults contains msg if {
|
||||
# Only check workflow files (not composite actions)
|
||||
input.jobs
|
||||
shell := input.defaults.run.shell
|
||||
shell != "bash"
|
||||
msg := sprintf(
|
||||
"Workflow has 'defaults.run.shell: %s' but it must be 'bash' to enable pipefail by default",
|
||||
[shell],
|
||||
)
|
||||
}
|
||||
|
||||
deny_github_script_without_retries contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/github-script@")
|
||||
not step["with"].retries
|
||||
msg := sprintf(
|
||||
concat("", [
|
||||
"actions/github-script in job '%s' must have 'retries' set ",
|
||||
"(e.g., retries: 3) for resilience against transient GitHub API failures.",
|
||||
]),
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
deny_scheduled_workflow_without_repo_check contains msg if {
|
||||
input["true"].schedule
|
||||
not any_job_has_repo_check(input.jobs)
|
||||
msg := "Scheduled workflows must have at least one job with 'if: github.repository == ...' condition"
|
||||
}
|
||||
|
||||
deny_push_without_branches contains msg if {
|
||||
"push" in object.keys(input["true"])
|
||||
not is_object(input["true"].push)
|
||||
msg := "Push trigger must have a branches filter to avoid running on every branch."
|
||||
}
|
||||
|
||||
deny_push_without_branches contains msg if {
|
||||
is_object(input["true"].push)
|
||||
not input["true"].push.branches
|
||||
msg := "Push trigger must have a branches filter to avoid running on every branch."
|
||||
}
|
||||
|
||||
deny_interpolation_in_run contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
regex.match(`\$\{\{`, step.run)
|
||||
msg := sprintf(
|
||||
concat("", [
|
||||
"Direct ${{ }} interpolation in run block of job '%s'. ",
|
||||
"Use env: to pass the value and reference it as $VAR in the script.",
|
||||
]),
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
deny_interpolation_in_run contains msg if {
|
||||
not input.jobs
|
||||
input.runs.steps
|
||||
some i, step in input.runs.steps
|
||||
regex.match(`\$\{\{`, step.run)
|
||||
msg := sprintf(
|
||||
concat("", [
|
||||
"Direct ${{ }} interpolation in run block of composite action step #%d. ",
|
||||
"Use env: to pass the value and reference it as $VAR in the script.",
|
||||
]),
|
||||
[i + 1],
|
||||
)
|
||||
}
|
||||
|
||||
deny_interpolation_in_github_script contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/github-script@")
|
||||
regex.match(`\$\{\{`, step["with"].script)
|
||||
msg := sprintf(
|
||||
concat("", [
|
||||
"Direct ${{ }} interpolation in github-script of job '%s'. ",
|
||||
"Use env: to pass the value and reference it as process.env.VAR in the script.",
|
||||
]),
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
deny_interpolation_in_github_script contains msg if {
|
||||
not input.jobs
|
||||
input.runs.steps
|
||||
some i, step in input.runs.steps
|
||||
startswith(step.uses, "actions/github-script@")
|
||||
regex.match(`\$\{\{`, step["with"].script)
|
||||
msg := sprintf(
|
||||
concat("", [
|
||||
"Direct ${{ }} interpolation in github-script of composite action step #%d. ",
|
||||
"Use env: to pass the value and reference it as process.env.VAR in the script.",
|
||||
]),
|
||||
[i + 1],
|
||||
)
|
||||
}
|
||||
|
||||
deny_interpolation_in_job_if contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
is_string(job["if"])
|
||||
regex.match(`\$\{\{`, job["if"])
|
||||
msg := sprintf(
|
||||
"Unnecessary ${{ }} in 'if' of job '%s'. Use quotes instead if the expression starts with '!' (e.g., if: \"!expr\").",
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
deny_interpolation_in_step_if contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
is_string(step["if"])
|
||||
regex.match(`\$\{\{`, step["if"])
|
||||
msg := sprintf(
|
||||
concat("", [
|
||||
"Unnecessary ${{ }} in 'if' of step '%s' in job '%s'. ",
|
||||
"Use quotes instead if the expression starts with '!' (e.g., if: \"!expr\").",
|
||||
]),
|
||||
[step.name, job_id],
|
||||
)
|
||||
}
|
||||
|
||||
contains_github_token(value) if {
|
||||
regex.match(`\$\{\{\s*github\.token\s*\}\}`, value)
|
||||
}
|
||||
|
||||
jobs_without_permissions(jobs) := {job_id |
|
||||
some job_id, job in jobs
|
||||
not job.permissions
|
||||
}
|
||||
|
||||
jobs_without_timeout(jobs) := {job_id |
|
||||
some job_id, job in jobs
|
||||
not job["timeout-minutes"]
|
||||
}
|
||||
|
||||
ubuntu_slim_jobs_with_long_timeout(jobs) := {job_id |
|
||||
some job_id, job in jobs
|
||||
job["runs-on"] == "ubuntu-slim"
|
||||
job["timeout-minutes"] > 15
|
||||
}
|
||||
|
||||
is_step_unpinned(step) if {
|
||||
not startswith(step.uses, "./")
|
||||
not regex.match(`^[^@]+@[0-9a-f]{40}$`, step.uses)
|
||||
}
|
||||
|
||||
unpinned_actions(inp) := unpinned if {
|
||||
# For workflow files with jobs
|
||||
inp.jobs
|
||||
unpinned := {step.uses |
|
||||
some job in inp.jobs
|
||||
some step in job.steps
|
||||
is_step_unpinned(step)
|
||||
}
|
||||
}
|
||||
|
||||
unpinned_actions(inp) := unpinned if {
|
||||
# For composite action files with runs
|
||||
not inp.jobs
|
||||
inp.runs.steps
|
||||
unpinned := {step.uses |
|
||||
some step in inp.runs.steps
|
||||
is_step_unpinned(step)
|
||||
}
|
||||
}
|
||||
|
||||
any_job_has_repo_check(jobs) if {
|
||||
some job in jobs
|
||||
job_has_repo_check(job)
|
||||
}
|
||||
|
||||
job_has_repo_check(job) if {
|
||||
regex.match(`github\.repository\s*==\s*'mlflow/`, job["if"])
|
||||
}
|
||||
|
||||
deny_secrets_in_top_level_env contains msg if {
|
||||
some key, value in input.env
|
||||
contains_secret(value)
|
||||
msg := sprintf(
|
||||
"Secret in top-level env.%s. Move secrets to step-level env for least-privilege scope.",
|
||||
[key],
|
||||
)
|
||||
}
|
||||
|
||||
deny_secrets_in_job_level_env contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some key, value in job.env
|
||||
contains_secret(value)
|
||||
msg := sprintf(
|
||||
"Secret in job-level env.%s of job '%s'. Move secrets to step-level env for least-privilege scope.",
|
||||
[key, job_id],
|
||||
)
|
||||
}
|
||||
|
||||
contains_secret(value) if {
|
||||
regex.match(`\$\{\{\s*secrets\.`, value)
|
||||
}
|
||||
|
||||
deny_checkout_missing_persist_credentials contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/checkout@")
|
||||
not has_explicit_persist_credentials(step)
|
||||
msg := sprintf(
|
||||
"actions/checkout in job '%s' must set 'persist-credentials' explicitly (false for read-only, true if pushing).",
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
has_explicit_persist_credentials(step) if {
|
||||
step["with"]["persist-credentials"] == false
|
||||
}
|
||||
|
||||
has_explicit_persist_credentials(step) if {
|
||||
step["with"]["persist-credentials"] == true
|
||||
}
|
||||
|
||||
deny_upload_artifact_without_retention contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/upload-artifact@")
|
||||
not step["with"]["retention-days"]
|
||||
msg := sprintf(
|
||||
"actions/upload-artifact in job '%s' must set 'retention-days' explicitly.",
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
deny_upload_artifact_without_if_no_files_found contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
startswith(step.uses, "actions/upload-artifact@")
|
||||
not step["with"]["if-no-files-found"]
|
||||
msg := sprintf(
|
||||
"actions/upload-artifact in job '%s' must set 'if-no-files-found' explicitly.",
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
deny_matrix_without_fail_fast contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
job.strategy.matrix
|
||||
not has_explicit_fail_fast(job.strategy)
|
||||
msg := sprintf(
|
||||
"strategy.matrix in job '%s' must set 'fail-fast' explicitly (either true or false).",
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
has_explicit_fail_fast(strategy) if {
|
||||
strategy["fail-fast"] == false
|
||||
}
|
||||
|
||||
has_explicit_fail_fast(strategy) if {
|
||||
strategy["fail-fast"] == true
|
||||
}
|
||||
|
||||
deny_mutable_install contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
some line in split(step.run, "\n")
|
||||
regex.match(`\bnpm install\b`, line)
|
||||
not regex.match(`--package-lock-only\b`, line)
|
||||
msg := sprintf(
|
||||
"'npm install' in job '%s' modifies the lockfile. Use 'npm ci' for reproducible builds.",
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
|
||||
deny_mutable_install contains msg if {
|
||||
some job_id, job in input.jobs
|
||||
some step in job.steps
|
||||
regex.match(`(?m)^\s*yarn(\s+install)?\s*(?:#.*)?$`, step.run)
|
||||
not regex.match(`\byarn install\s+--immutable\b`, step.run)
|
||||
msg := sprintf(
|
||||
"yarn or yarn install in job '%s' may modify the lockfile. Use 'yarn install --immutable'.",
|
||||
[job_id],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<!--
|
||||
Do not remove any sections. Remove unused checkboxes except for the patch release section at the bottom.
|
||||
Use backticks for code references and file paths in the PR title (e.g., `ClassName`, `function_name`, `utils.py`).
|
||||
-->
|
||||
|
||||
### Related Issues/PRs
|
||||
|
||||
<!-- 🚨 Choose either "Closes" (auto-close on merge) or "Relates to" (reference only). 🚨 -->
|
||||
|
||||
Closes | Relates to #issue_number
|
||||
|
||||
### What changes are proposed in this pull request?
|
||||
|
||||
<!-- Please fill in changes proposed in this PR. -->
|
||||
|
||||
### How is this PR tested?
|
||||
|
||||
- [ ] Existing unit/integration tests
|
||||
- [ ] New unit/integration tests
|
||||
- [ ] Manual tests
|
||||
|
||||
<!-- Attach code, screenshot, video used for manual testing here. -->
|
||||
|
||||
### Does this PR require documentation update?
|
||||
|
||||
- [ ] No.
|
||||
- [ ] Yes. I've updated:
|
||||
- [ ] Examples
|
||||
- [ ] API references
|
||||
- [ ] Instructions
|
||||
|
||||
### Does this PR require updating the [MLflow Skills](https://github.com/mlflow/skills) repository?
|
||||
|
||||
<!-- When updating APIs or feature usage, please ensure the MLflow Skills repository reflects those changes. -->
|
||||
|
||||
- [ ] No.
|
||||
- [ ] Yes. Please link the corresponding PR or explain how you plan to update it.
|
||||
|
||||
<!-- Provide the link to the Skills repository PR or a brief explanation of the changes needed. -->
|
||||
|
||||
### Release Notes
|
||||
|
||||
#### Is this a user-facing change?
|
||||
|
||||
- [ ] No.
|
||||
- [ ] Yes. Give a description of this change to be included in the release notes for MLflow users.
|
||||
|
||||
<!-- Details in 1-2 sentences. You can just refer to another PR with a description if this PR is part of a larger change. -->
|
||||
|
||||
#### What component(s), interfaces, languages, and integrations does this PR affect?
|
||||
|
||||
Components
|
||||
|
||||
- [ ] `area/tracking`: Tracking Service, tracking client APIs, autologging
|
||||
- [ ] `area/models`: MLmodel format, model serialization/deserialization, flavors
|
||||
- [ ] `area/model-registry`: Model Registry service, APIs, and the fluent client calls for Model Registry
|
||||
- [ ] `area/scoring`: MLflow Model server, model deployment tools, Spark UDFs
|
||||
- [ ] `area/evaluation`: MLflow model evaluation features, evaluation metrics, and evaluation workflows
|
||||
- [ ] `area/gateway`: MLflow AI Gateway client APIs, server, and third-party integrations
|
||||
- [ ] `area/prompts`: MLflow prompt engineering features, prompt templates, and prompt management
|
||||
- [ ] `area/tracing`: MLflow Tracing features, tracing APIs, and LLM tracing functionality
|
||||
- [ ] `area/projects`: MLproject format, project running backends
|
||||
- [ ] `area/uiux`: Front-end, user experience, plotting, JavaScript, JavaScript dev server
|
||||
- [ ] `area/build`: Build and test infrastructure for MLflow
|
||||
- [ ] `area/docs`: MLflow documentation pages
|
||||
|
||||
<!--
|
||||
Insert an empty named anchor here to allow jumping to this section with a fragment URL
|
||||
(e.g. https://github.com/mlflow/mlflow/pull/123#user-content-release-note-category).
|
||||
Note that GitHub prefixes anchor names in markdown with "user-content-".
|
||||
-->
|
||||
|
||||
<a name="release-note-category"></a>
|
||||
|
||||
#### How should the PR be classified in the release notes? Choose one:
|
||||
|
||||
- [ ] `rn/none` - No description will be included. The PR will be mentioned only by the PR number in the "Small Bugfixes and Documentation Updates" section
|
||||
- [ ] `rn/breaking-change` - The PR will be mentioned in the "Breaking Changes" section
|
||||
- [ ] `rn/feature` - A new user-facing feature worth mentioning in the release notes
|
||||
- [ ] `rn/bug-fix` - A user-facing bug fix worth mentioning in the release notes
|
||||
- [ ] `rn/documentation` - A user-facing documentation change worth mentioning in the release notes
|
||||
|
||||
#### Is this PR a critical bugfix or security fix that should go into the next patch release?
|
||||
|
||||
<details>
|
||||
<summary>What is a minor/patch release?</summary>
|
||||
|
||||
- Minor release: a release that increments the second part of the version number (e.g., 1.2.0 -> 1.3.0).
|
||||
Minor releases are expected to contain larger changes, such as new features and improvements. Non-critical bug fixes and doc updates can be included as well. By default, your PR should target the next minor release.
|
||||
- Patch release: a release that increments the third part of the version number (e.g., 1.2.0 -> 1.2.1).
|
||||
Patch releases are typically only performed when there has been a major regression or bug in the latest release. For the sake of stability, your PR should not be included in a patch release unless it is a critical fix, or if the risk level of your PR is exceedingly low.
|
||||
|
||||
</details>
|
||||
|
||||
<!-- Do not modify or remove any text inside the parentheses. Keep both checkboxes below. -->
|
||||
|
||||
- [ ] This PR is critical and needs to be in the next patch release
|
||||
- [ ] This PR can wait for the next minor release
|
||||
@@ -0,0 +1,56 @@
|
||||
# UI Preview
|
||||
|
||||
Deploy a live preview of the MLflow UI as a [Databricks App](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/) when a PR modifies the frontend (`mlflow/server/js/`).
|
||||
|
||||
## How it works
|
||||
|
||||
1. Add the `ui-preview` label to a PR with UI changes
|
||||
2. The [UI Preview workflow](../workflows/ui-preview.yml) builds the frontend and deploys it to a Databricks App
|
||||
3. A comment with the preview URL is posted on the PR
|
||||
4. The app is automatically deleted when the PR is closed
|
||||
|
||||
## Access
|
||||
|
||||
Preview apps are only accessible to core maintainers with workspace access.
|
||||
|
||||
## API access
|
||||
|
||||
To query or add data to a preview app, set the following environment variables:
|
||||
|
||||
```bash
|
||||
export DATABRICKS_HOST="https://..."
|
||||
export DATABRICKS_CLIENT_ID="..."
|
||||
export DATABRICKS_CLIENT_SECRET="..."
|
||||
export APP_URL="..."
|
||||
```
|
||||
|
||||
Then, obtain an access token:
|
||||
|
||||
```bash
|
||||
export TOKEN=$(curl -s -X POST "$DATABRICKS_HOST/oidc/v1/token" \
|
||||
-d "grant_type=client_credentials&client_id=$DATABRICKS_CLIENT_ID&client_secret=$DATABRICKS_CLIENT_SECRET&scope=all-apis" \
|
||||
| jq -r '.access_token')
|
||||
```
|
||||
|
||||
Once the token is obtained, run the following command to verify it works:
|
||||
|
||||
```bash
|
||||
curl -s "$APP_URL/api/2.0/mlflow/experiments/search" \
|
||||
-X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"max_results": 10}' | jq .
|
||||
```
|
||||
|
||||
You can also use the MLflow Python client:
|
||||
|
||||
```bash
|
||||
export MLFLOW_TRACKING_URI="$APP_URL"
|
||||
export MLFLOW_TRACKING_TOKEN="$TOKEN"
|
||||
```
|
||||
|
||||
```python
|
||||
import mlflow
|
||||
|
||||
mlflow.search_experiments(max_results=10)
|
||||
```
|
||||
|
||||
See [Connect to Databricks Apps](https://docs.databricks.com/aws/en/dev-tools/databricks-apps/connect-local) for more details on authentication.
|
||||
@@ -0,0 +1,57 @@
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import mlflow.server
|
||||
from mlflow.demo import generate_all_demos
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup():
|
||||
# Extract UI build assets into the mlflow package's expected location
|
||||
tar_path = Path(__file__).parent.resolve() / "build.tar.gz"
|
||||
target_dir = Path(mlflow.server.__file__).parent / "js"
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_logger.info("Extracting UI assets to %s", target_dir)
|
||||
subprocess.check_call(["tar", "xzf", tar_path, "-C", target_dir])
|
||||
|
||||
# Generate demo data. Always refresh so the preview app reflects the latest
|
||||
# demo content (e.g. new trace types) even if the SQLite database persisted
|
||||
# from a previous deploy with stale demo data.
|
||||
os.environ["MLFLOW_TRACKING_URI"] = "sqlite:///mlflow.db"
|
||||
_logger.info("Generating demo data...")
|
||||
generate_all_demos(refresh=True)
|
||||
_logger.info("Demo data generated.")
|
||||
|
||||
|
||||
def main():
|
||||
setup()
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"mlflow",
|
||||
"server",
|
||||
"--backend-store-uri",
|
||||
"sqlite:///mlflow.db",
|
||||
"--default-artifact-root",
|
||||
"./mlartifacts",
|
||||
"--serve-artifacts",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"8000",
|
||||
"--workers",
|
||||
"1",
|
||||
]
|
||||
_logger.info("Starting MLflow server: %s", " ".join(cmd))
|
||||
os.execvp(cmd[0], cmd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,10 @@
|
||||
command:
|
||||
- "python"
|
||||
- "app.py"
|
||||
env:
|
||||
- name: MLFLOW_SERVER_CORS_ALLOWED_ORIGINS
|
||||
value: "__APP_URL__"
|
||||
- name: MLFLOW_SERVER_ALLOWED_HOSTS
|
||||
value: "*"
|
||||
- name: MLFLOW_CRYPTO_KEK_PASSPHRASE
|
||||
value: "__KEK_PASSPHRASE__"
|
||||
@@ -0,0 +1,190 @@
|
||||
const ACTIVITY_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const MAX_REPOS_TO_DISPLAY = 10;
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function getRecentActivity(github, username) {
|
||||
const windowStart = new Date(Date.now() - ACTIVITY_WINDOW_MS);
|
||||
const dateString = windowStart.toISOString().slice(0, 10);
|
||||
const query = `type:pr author:${username} created:>${dateString}`;
|
||||
const items = await github.paginate(github.rest.search.issuesAndPullRequests, {
|
||||
q: query,
|
||||
per_page: 100,
|
||||
});
|
||||
const repoCounts = new Map();
|
||||
for (const item of items) {
|
||||
const repoFullName = item.repository_url.replace("https://api.github.com/repos/", "");
|
||||
if (!repoCounts.has(repoFullName)) {
|
||||
repoCounts.set(repoFullName, { open: 0, closed: 0, merged: 0 });
|
||||
}
|
||||
const counts = repoCounts.get(repoFullName);
|
||||
if (item.pull_request?.merged_at) {
|
||||
counts.merged++;
|
||||
} else if (item.state === "closed") {
|
||||
counts.closed++;
|
||||
} else {
|
||||
counts.open++;
|
||||
}
|
||||
}
|
||||
return { totalPRs: items.length, repoCount: repoCounts.size, repoBreakdown: repoCounts };
|
||||
}
|
||||
|
||||
async function getRecentActivitySection(github, username) {
|
||||
const { totalPRs, repoCount, repoBreakdown } = await getRecentActivity(github, username);
|
||||
if (totalPRs === 0) {
|
||||
return "";
|
||||
}
|
||||
const prLabel = totalPRs === 1 ? "PR" : "PRs";
|
||||
const repoLabel = repoCount === 1 ? "repo" : "repos";
|
||||
const total = ({ open, closed, merged }) => open + closed + merged;
|
||||
const sortedRepos = [...repoBreakdown.entries()]
|
||||
.sort((a, b) => total(b[1]) - total(a[1]))
|
||||
.slice(0, MAX_REPOS_TO_DISPLAY);
|
||||
const tableRows = sortedRepos
|
||||
.map(
|
||||
([repo, counts]) =>
|
||||
`| [${repo}](https://github.com/${repo}/pulls/${username}) | ${counts.open} | ${
|
||||
counts.closed
|
||||
} | ${counts.merged} | ${total(counts)} |`
|
||||
)
|
||||
.join("\n");
|
||||
const topNote = repoCount > MAX_REPOS_TO_DISPLAY ? ` (showing top ${MAX_REPOS_TO_DISPLAY})` : "";
|
||||
return `
|
||||
<details><summary>PR author's recent activity</summary>
|
||||
|
||||
In the last 14 days, @${username} opened **${totalPRs} ${prLabel}** across **${repoCount} ${repoLabel}**${topNote}:
|
||||
|
||||
| Repository | Open | Closed | Merged | Total |
|
||||
| ---------- | ---- | ------ | ------ | ----- |
|
||||
${tableRows}
|
||||
|
||||
</details>`;
|
||||
}
|
||||
|
||||
async function getDcoCheck(github, owner, repo, sha) {
|
||||
const backoffs = [0, 2, 4, 6, 8];
|
||||
const numAttempts = backoffs.length;
|
||||
for (const [index, backoff] of backoffs.entries()) {
|
||||
await sleep(backoff * 1000);
|
||||
const resp = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: sha,
|
||||
app_id: 1861, // ID of the DCO check app
|
||||
});
|
||||
|
||||
const { check_runs } = resp.data;
|
||||
if (check_runs.length > 0 && check_runs[0].status === "completed") {
|
||||
return check_runs[0];
|
||||
}
|
||||
console.log(`[Attempt ${index + 1}/${numAttempts}]`, "The DCO check hasn't completed yet.");
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = async ({ context, github }) => {
|
||||
const { owner, repo } = context.repo;
|
||||
const { number: issue_number } = context.issue;
|
||||
const { sha, label } = context.payload.pull_request.head;
|
||||
const { user, body } = context.payload.pull_request;
|
||||
const messages = [];
|
||||
|
||||
const title = "Install mlflow from this PR";
|
||||
// Check if an install comment already exists
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
});
|
||||
const installCommentExists = comments.some((comment) => comment.body.includes(title));
|
||||
|
||||
if (!installCommentExists) {
|
||||
let activitySection = "";
|
||||
const memberAssociations = ["MEMBER", "OWNER", "COLLABORATOR"];
|
||||
if (
|
||||
user.type !== "Bot" &&
|
||||
!memberAssociations.includes(context.payload.pull_request.author_association)
|
||||
) {
|
||||
try {
|
||||
activitySection = await getRecentActivitySection(github, user.login);
|
||||
} catch (e) {
|
||||
console.log("Failed to fetch recent activity:", e);
|
||||
}
|
||||
}
|
||||
const devToolsComment = `
|
||||
<details><summary>${title}</summary>
|
||||
<p>
|
||||
|
||||
#### Install mlflow from this PR
|
||||
|
||||
\`\`\`bash
|
||||
# mlflow
|
||||
pip install git+https://github.com/mlflow/mlflow.git@refs/pull/${issue_number}/merge
|
||||
# mlflow-skinny
|
||||
pip install git+https://github.com/mlflow/mlflow.git@refs/pull/${issue_number}/merge#subdirectory=libs/skinny
|
||||
\`\`\`
|
||||
|
||||
For Databricks, use the following command:
|
||||
|
||||
\`\`\`bash
|
||||
%sh curl -LsSf https://raw.githubusercontent.com/mlflow/mlflow/HEAD/dev/install-skinny.sh | sh -s pull/${issue_number}/merge
|
||||
\`\`\`
|
||||
|
||||
</p>
|
||||
</details>
|
||||
${activitySection}
|
||||
`.trim();
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: devToolsComment,
|
||||
});
|
||||
}
|
||||
|
||||
// Exit early if the PR author is a bot
|
||||
if (user.type === "Bot") {
|
||||
return;
|
||||
}
|
||||
|
||||
const dcoCheck = await getDcoCheck(github, owner, repo, sha);
|
||||
if (dcoCheck && dcoCheck.conclusion !== "success") {
|
||||
messages.push(
|
||||
"#### ❌ DCO check\n\n" +
|
||||
"The DCO check failed. " +
|
||||
`Please sign off your commit(s) by following the instructions [here](${dcoCheck.html_url}). ` +
|
||||
"See https://github.com/mlflow/mlflow/blob/master/CONTRIBUTING.md#sign-your-work for more " +
|
||||
"details."
|
||||
);
|
||||
}
|
||||
|
||||
if (label.endsWith(":master")) {
|
||||
messages.push(
|
||||
"#### ❌ PR branch check\n\n" +
|
||||
"This PR was filed from the master branch in your fork, which is not recommended " +
|
||||
"and may cause our CI checks to fail. Please close this PR and file a new PR from " +
|
||||
"a non-master branch."
|
||||
);
|
||||
}
|
||||
|
||||
if (!(body || "").includes("How should the PR be classified in the release notes?")) {
|
||||
messages.push(
|
||||
"#### ❌ Invalid PR template\n\n" +
|
||||
"The PR description is missing required sections. " +
|
||||
"Please use the [PR template](https://raw.githubusercontent.com/mlflow/mlflow/master/.github/pull_request_template.md)."
|
||||
);
|
||||
}
|
||||
|
||||
if (messages.length > 0) {
|
||||
const body =
|
||||
`@${user.login} Thank you for the contribution! Could you fix the following issue(s)? Otherwise, this PR may be automatically closed.\n\n` +
|
||||
messages.join("\n\n");
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Advice
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
if: >
|
||||
!(github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
pull-requests: write # advice.js comments on PRs
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/workflows/advice.js`
|
||||
);
|
||||
await script({ context, github });
|
||||
@@ -0,0 +1,109 @@
|
||||
async function getMaintainers({ github, context }) {
|
||||
const collaborators = await github.paginate(github.rest.repos.listCollaborators, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
});
|
||||
return collaborators
|
||||
.filter(({ role_name }) => ["admin", "maintain"].includes(role_name))
|
||||
.map(({ login }) => login)
|
||||
.sort();
|
||||
}
|
||||
|
||||
const EXEMPTION_RULES = [
|
||||
// Exemption for GenAI evaluation PRs.
|
||||
{
|
||||
authors: ["alkispoly-db", "AveshCSingh", "danielseong1", "smoorjani", "SomtochiUmeh", "xsh310"],
|
||||
allowedPatterns: [
|
||||
/^mlflow\/genai\//,
|
||||
/^tests\/genai\//,
|
||||
/^docs\//,
|
||||
/^mlflow\/entities\/(assessment|dataset|evaluation|scorer)/,
|
||||
],
|
||||
excludedPatterns: [/^mlflow\/genai\/(agent_server|git_versioning|prompts|optimize)\//],
|
||||
},
|
||||
// Exemption for UI PRs.
|
||||
{
|
||||
authors: ["daniellok-db", "danielseong1", "hubertzub-db"],
|
||||
allowedPatterns: [/^mlflow\/server\/js\//],
|
||||
},
|
||||
];
|
||||
|
||||
function matchesAnyPattern(path, patterns) {
|
||||
if (!patterns) {
|
||||
return false;
|
||||
}
|
||||
return patterns.some((pattern) => pattern.test(path));
|
||||
}
|
||||
|
||||
function isAllowedPath(path, rule) {
|
||||
return (
|
||||
matchesAnyPattern(path, rule.allowedPatterns) && !matchesAnyPattern(path, rule.excludedPatterns)
|
||||
);
|
||||
}
|
||||
|
||||
function isExempted(authorLogin, files) {
|
||||
let filesToCheck = files;
|
||||
for (const rule of EXEMPTION_RULES) {
|
||||
if (rule.authors.includes(authorLogin)) {
|
||||
filesToCheck = filesToCheck.filter(
|
||||
({ filename, previous_filename }) =>
|
||||
// Keep files where NOT all before/after file paths are allowed by the rule.
|
||||
![filename, previous_filename].filter(Boolean).every((path) => isAllowedPath(path, rule))
|
||||
);
|
||||
if (filesToCheck.length === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasAnyApproval(reviews) {
|
||||
return reviews.some(({ state }) => state === "APPROVED");
|
||||
}
|
||||
|
||||
module.exports = async ({ github, context, core }) => {
|
||||
const { pull_request: pr } = context.payload;
|
||||
const authorLogin = pr?.user?.login;
|
||||
if (authorLogin === "mlflow-app[bot]") {
|
||||
return;
|
||||
}
|
||||
|
||||
const maintainers = await getMaintainers({ github, context });
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
});
|
||||
const APPROVED_BOTS = new Set(["nailaopus[bot]"]);
|
||||
const maintainerApproved = reviews.some(
|
||||
({ state, user }) =>
|
||||
state === "APPROVED" &&
|
||||
// GitHub returns `user: null` on reviews from accounts that have since
|
||||
// been deleted; skip them rather than crashing on `user.login`.
|
||||
user &&
|
||||
(maintainers.includes(user.login) ||
|
||||
(user.type.toLowerCase() === "bot" && APPROVED_BOTS.has(user.login)))
|
||||
);
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
});
|
||||
|
||||
if (isExempted(authorLogin, files)) {
|
||||
if (!hasAnyApproval(reviews)) {
|
||||
core.setFailed(
|
||||
"PR from exempted author needs at least one approval (maintainer approval not required)."
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!maintainerApproved) {
|
||||
const maintainerList = maintainers.join(", ");
|
||||
const message = `This PR requires an approval from at least one of the core maintainers: ${maintainerList}.`;
|
||||
core.setFailed(message);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Approval
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- name: Fail without core maintainer approval
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require(`${process.env.GITHUB_WORKSPACE}/.github/workflows/approval.js`);
|
||||
await script({ context, github, core });
|
||||
@@ -0,0 +1,184 @@
|
||||
async function getMaintainers({ github, context }) {
|
||||
const collaborators = await github.paginate(github.rest.repos.listCollaborators, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
});
|
||||
return collaborators
|
||||
.filter(
|
||||
({ role_name, login }) =>
|
||||
["admin", "maintain"].includes(role_name) ||
|
||||
[
|
||||
"alkispoly-db",
|
||||
"AveshCSingh",
|
||||
"danielseong1",
|
||||
"smoorjani",
|
||||
"SomtochiUmeh",
|
||||
"xsh310",
|
||||
].includes(login)
|
||||
)
|
||||
.map(({ login }) => login)
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function getLinkedIssues({ github, owner, repo, prNumber }) {
|
||||
const query = `
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
createdAt
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
number
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const result = await github.graphql(query, {
|
||||
owner,
|
||||
repo,
|
||||
number: prNumber,
|
||||
});
|
||||
|
||||
return {
|
||||
prCreatedAt: result.repository.pullRequest.createdAt,
|
||||
issues: result.repository.pullRequest.closingIssuesReferences.nodes,
|
||||
};
|
||||
}
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
async function findFirstMaintainerInIssueComments({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber,
|
||||
maintainers,
|
||||
}) {
|
||||
for await (const response of github.paginate.iterator(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
})) {
|
||||
for (const comment of response.data) {
|
||||
if (maintainers.has(comment.user.login)) {
|
||||
return comment.user.login;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findMaintainerFromLinkedIssue({ github, owner, repo, prNumber, maintainers }) {
|
||||
const { prCreatedAt, issues: linkedIssues } = await getLinkedIssues({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
prNumber,
|
||||
});
|
||||
|
||||
if (linkedIssues.length !== 1 || !prCreatedAt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const linkedIssue = linkedIssues[0];
|
||||
const prCreatedDate = new Date(prCreatedAt);
|
||||
const issueCreatedDate = new Date(linkedIssue.createdAt);
|
||||
|
||||
if (prCreatedDate - issueCreatedDate > SEVEN_DAYS_MS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return findFirstMaintainerInIssueComments({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
issueNumber: linkedIssue.number,
|
||||
maintainers,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = async ({ github, context, skipAssignment = false }) => {
|
||||
const { owner, repo } = context.repo;
|
||||
const maintainers = new Set(await getMaintainers({ github, context }));
|
||||
|
||||
// Get current time minus 200 minutes to look for recent comments and PRs
|
||||
const lookbackTime = new Date(Date.now() - 200 * 60 * 1000);
|
||||
|
||||
// Use search API to find recently updated open PRs
|
||||
const searchQuery = `repo:${owner}/${repo} is:pr is:open updated:>=${lookbackTime.toISOString()}`;
|
||||
const searchResults = await github.paginate(github.rest.search.issuesAndPullRequests, {
|
||||
q: searchQuery,
|
||||
sort: "updated",
|
||||
order: "desc",
|
||||
});
|
||||
|
||||
console.log(`Scanning ${searchResults.length} recently updated PRs`);
|
||||
|
||||
for (const pr of searchResults) {
|
||||
// Get recent comments and reviews
|
||||
const issueComments = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
since: lookbackTime.toISOString(),
|
||||
});
|
||||
const reviews = await github.rest.pulls.listReviews({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
});
|
||||
|
||||
// Filter reviews by lookback time and extract authors
|
||||
const recentReviews = reviews.data.filter((r) => new Date(r.submitted_at) > lookbackTime);
|
||||
const commentAuthors = new Set([
|
||||
...issueComments.data.map((c) => c.user.login),
|
||||
...recentReviews.map((r) => r.user.login),
|
||||
]);
|
||||
|
||||
// Use Set operations to find maintainers to assign
|
||||
const prAuthor = pr.user.login;
|
||||
const currentAssignees = new Set(pr.assignees.map((a) => a.login));
|
||||
const excludeSet = new Set([prAuthor, ...currentAssignees]);
|
||||
|
||||
let maintainersToAssign = [...commentAuthors.intersection(maintainers).difference(excludeSet)];
|
||||
|
||||
// Fall back to linked issue comments if no maintainers found from recent PR activity
|
||||
if (maintainersToAssign.length === 0) {
|
||||
const maintainer = await findMaintainerFromLinkedIssue({
|
||||
github,
|
||||
owner,
|
||||
repo,
|
||||
prNumber: pr.number,
|
||||
maintainers,
|
||||
});
|
||||
if (maintainer && !excludeSet.has(maintainer)) {
|
||||
maintainersToAssign = [maintainer];
|
||||
}
|
||||
}
|
||||
|
||||
if (maintainersToAssign.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Assign maintainers
|
||||
if (!skipAssignment) {
|
||||
await github.rest.issues.addAssignees({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
assignees: maintainersToAssign,
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
`${skipAssignment ? "[DRY RUN] Would assign" : "Assigned"} [${maintainersToAssign.join(
|
||||
", "
|
||||
)}] to PR #${pr.number}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Scan completed");
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Auto-assign maintainer
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every 3 hours to check for new maintainer comments
|
||||
- cron: "0 */3 * * *"
|
||||
workflow_dispatch: # Allow manual triggering
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/auto-assign.yml
|
||||
- .github/workflows/auto-assign.js
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
scan-and-assign:
|
||||
if: github.repository == 'mlflow/mlflow'
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require('./.github/workflows/auto-assign.js');
|
||||
const skipAssignment = context.eventName === 'pull_request';
|
||||
await script({ github, context, skipAssignment });
|
||||
@@ -0,0 +1,230 @@
|
||||
// Auto-close PRs based on linked-issue policy:
|
||||
// 1. PRs that attempt to close an issue without the "ready" label.
|
||||
// 2. PRs that don't link to any issue and change more than LOC_THRESHOLD
|
||||
// lines.
|
||||
// Skips PRs that reference multiple issues (ambiguous intent).
|
||||
// Only enforces on issues/PRs created on or after 2026-03-10.
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const READY_LABEL = "ready";
|
||||
const PR_TEMPLATE_PATH = ".github/pull_request_template.md";
|
||||
// The date we introduced the "ready" label policy; skip older issues/PRs.
|
||||
const CUTOFF_DATE = new Date("2026-03-10T00:00:00Z");
|
||||
// PRs with more than this many LOC changed must link to an issue.
|
||||
const LOC_THRESHOLD = 100;
|
||||
|
||||
const QUERY = `
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
number
|
||||
createdAt
|
||||
labels(first: 50) {
|
||||
nodes { name }
|
||||
}
|
||||
assignees(first: 10) {
|
||||
nodes { login }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function getTemplateHeadings() {
|
||||
const templatePath = path.join(process.env.GITHUB_WORKSPACE, PR_TEMPLATE_PATH);
|
||||
try {
|
||||
return fs
|
||||
.readFileSync(templatePath, "utf8")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => /^#+\s/.test(line));
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read PR template at ${templatePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function hasIssueReference(body) {
|
||||
if (!body) return false;
|
||||
// Strip fenced and inline code blocks so references mentioned inside code
|
||||
// samples don't count.
|
||||
const stripped = body
|
||||
.replace(/```[\s\S]*?```/g, "")
|
||||
.replace(/~~~[\s\S]*?~~~/g, "")
|
||||
.replace(/`[^`\n]*`/g, "");
|
||||
// Match `#123`, `owner/repo#123`, or an issue/PR URL.
|
||||
const shortRef = /(?:[\w.-]+\/[\w.-]+)?#\d+/;
|
||||
const urlRef = /https?:\/\/github\.com\/[\w.-]+\/[\w.-]+\/(?:issues|pull)\/\d+/;
|
||||
return shortRef.test(stripped) || urlRef.test(stripped);
|
||||
}
|
||||
|
||||
function getMissingHeadings(body, headings) {
|
||||
if (!body) return headings;
|
||||
const bodyLines = new Set(body.split("\n").map((line) => line.trim()));
|
||||
return headings.filter((h) => !bodyLines.has(h));
|
||||
}
|
||||
|
||||
async function isDatabricksAuthor({ github, context }) {
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
|
||||
// Check user profile for Databricks affiliation
|
||||
const { data: user } = await github.rest.users.getByUsername({ username: prAuthor });
|
||||
if ([user.company, user.email].some((v) => /databricks/i.test(v || ""))) return true;
|
||||
|
||||
// Check commit author emails for @databricks.com
|
||||
const commits = await github.paginate(github.rest.pulls.listCommits, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
return commits.some((c) => /@databricks\.com$/i.test(c.commit.author.email || ""));
|
||||
}
|
||||
|
||||
async function getCloseReason({ github, context }) {
|
||||
const association = context.payload.pull_request.author_association;
|
||||
if (["OWNER", "MEMBER", "COLLABORATOR"].includes(association)) return undefined;
|
||||
if (context.payload.pull_request.user.type === "Bot") return undefined;
|
||||
|
||||
if (await isDatabricksAuthor({ github, context })) {
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
console.log(`PR author @${prAuthor} has Databricks affiliation. Skipping.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// Check that the PR body follows the PR template
|
||||
const templateHeadings = getTemplateHeadings();
|
||||
const prBody = context.payload.pull_request.body;
|
||||
const missingHeadings = getMissingHeadings(prBody, templateHeadings);
|
||||
const missingRatio = missingHeadings.length / templateHeadings.length;
|
||||
console.log(
|
||||
`PR #${prNumber} is missing ${missingHeadings.length}/${templateHeadings.length} template section(s).`
|
||||
);
|
||||
if (missingRatio > 0.5) {
|
||||
const missingList = missingHeadings.map((h) => `- ${h.replace(/^#+\s*/, "")}`).join("\n");
|
||||
return [
|
||||
"This PR was automatically closed because it does not follow the PR template.",
|
||||
`<details>\n<summary>Missing sections</summary>\n\n${missingList}\n</details>`,
|
||||
`Please update your PR body to include all sections from the [PR template](https://github.com/${owner}/${repo}/blob/master/${PR_TEMPLATE_PATH}) and reopen this PR.`,
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
const response = await github.graphql(QUERY, { owner, repo, number: prNumber });
|
||||
const issues = response.repository.pullRequest.closingIssuesReferences.nodes;
|
||||
|
||||
if (issues.length === 0) {
|
||||
// closingIssuesReferences only catches closing keywords (Fixes/Closes/Resolves).
|
||||
// Also accept `#123`, `owner/repo#123`, or an issue/PR URL in the PR body.
|
||||
if (hasIssueReference(prBody)) {
|
||||
console.log(`PR #${prNumber} body contains an issue reference. Skipping.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const prCreatedAt = new Date(context.payload.pull_request.created_at);
|
||||
if (prCreatedAt < CUTOFF_DATE) {
|
||||
console.log(`PR #${prNumber} was created before ${CUTOFF_DATE.toISOString()}. Skipping.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { additions, deletions } = context.payload.pull_request;
|
||||
const totalChanges = additions + deletions;
|
||||
|
||||
if (totalChanges <= LOC_THRESHOLD) {
|
||||
console.log(
|
||||
`PR #${prNumber} has no linked issue but only ${totalChanges} LOC changed (<= ${LOC_THRESHOLD}). Skipping.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`PR #${prNumber} has no linked issue and ${totalChanges} LOC changed (> ${LOC_THRESHOLD}). Closing.`
|
||||
);
|
||||
return [
|
||||
"This PR was automatically closed because it does not link to an issue.",
|
||||
"Please open an issue describing the bug or feature first, wait for a maintainer to triage it, then link it from your PR description (e.g. `Fixes #123`).",
|
||||
"Please do not force-push to or delete the PR branch so this PR can be reopened.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
if (issues.length > 1) {
|
||||
console.log(
|
||||
`Multiple issues referenced (${issues.map((i) => `#${i.number}`).join(", ")}). Skipping.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const issue = issues[0];
|
||||
console.log(`PR #${prNumber} references issue #${issue.number}`);
|
||||
|
||||
// Skip issues created before the cutoff date
|
||||
if (new Date(issue.createdAt) < CUTOFF_DATE) {
|
||||
console.log(
|
||||
`Issue #${issue.number} was created before ${CUTOFF_DATE.toISOString()}. Skipping.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const hasReadyLabel = issue.labels.nodes.some((label) => label.name === READY_LABEL);
|
||||
if (!hasReadyLabel) {
|
||||
console.log(
|
||||
`Issue #${issue.number} is missing the "${READY_LABEL}" label. Closing PR #${prNumber}.`
|
||||
);
|
||||
return [
|
||||
`This PR was automatically closed because #${issue.number} is missing the \`${READY_LABEL}\` label.`,
|
||||
"Once a maintainer triages the issue and applies the label, feel free to reopen this PR.",
|
||||
"Please do not force-push to or delete the PR branch so this PR can be reopened.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
const assigneeLogins = issue.assignees.nodes.map((a) => a.login);
|
||||
if (assigneeLogins.length > 0 && !assigneeLogins.includes(prAuthor)) {
|
||||
const assigneeList = assigneeLogins.map((login) => `@${login}`).join(", ");
|
||||
console.log(
|
||||
`Issue #${issue.number} is assigned to ${assigneeList} but PR author is @${prAuthor}. Closing PR #${prNumber}.`
|
||||
);
|
||||
return [
|
||||
`This PR was automatically closed because #${issue.number} is assigned to ${assigneeList}.`,
|
||||
"If you believe this was done in error, please reach out to a maintainer.",
|
||||
"Please do not force-push to or delete the PR branch so this PR can be reopened.",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
console.log(`Issue #${issue.number} has the "${READY_LABEL}" label. No action needed.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function main({ context, github }) {
|
||||
const commentBody = await getCloseReason({ github, context });
|
||||
if (commentBody !== undefined) {
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const { owner, repo } = context.repo;
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: commentBody,
|
||||
});
|
||||
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
state: "closed",
|
||||
});
|
||||
|
||||
console.log(`PR #${prNumber} closed.`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { main, getCloseReason, isDatabricksAuthor };
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Auto Close PR
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
auto-close-pr:
|
||||
if: >-
|
||||
!contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)
|
||||
&& github.event.pull_request.user.type != 'Bot'
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/workflows/auto-close-pr.js`
|
||||
);
|
||||
await script.main({ context, github });
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Autoformat Label Notification
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-slim
|
||||
if: github.event.label.name == 'autoformat'
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: write # to post a comment on the PR
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh pr comment "$PR" --repo "$REPO" --body 'Please use `/autoformat` command instead of labels.'
|
||||
gh pr edit "$PR" --repo "$REPO" --remove-label autoformat
|
||||
@@ -0,0 +1,217 @@
|
||||
const createCommitStatus = async (context, github, sha, state) => {
|
||||
const { workflow, runId } = context;
|
||||
const { owner, repo } = context.repo;
|
||||
const target_url = `https://github.com/${owner}/${repo}/actions/runs/${runId}?pr=${context.issue.number}`;
|
||||
await github.rest.repos.createCommitStatus({
|
||||
owner,
|
||||
repo,
|
||||
sha,
|
||||
state,
|
||||
target_url,
|
||||
description: sha,
|
||||
context: workflow,
|
||||
});
|
||||
};
|
||||
|
||||
const shouldAutoformat = (comment) => {
|
||||
return comment.body.trim() === "/autoformat";
|
||||
};
|
||||
|
||||
const getPullInfo = async (context, github) => {
|
||||
const { owner, repo } = context.repo;
|
||||
const pull_number = context.issue.number;
|
||||
const pr = await github.rest.pulls.get({ owner, repo, pull_number });
|
||||
const {
|
||||
sha: head_sha,
|
||||
ref: head_ref,
|
||||
repo: { full_name },
|
||||
} = pr.data.head;
|
||||
const { sha: base_sha, ref: base_ref, repo: base_repo } = pr.data.base;
|
||||
return {
|
||||
repository: full_name,
|
||||
pull_number,
|
||||
head_sha,
|
||||
head_ref,
|
||||
base_sha,
|
||||
base_ref,
|
||||
base_repo: base_repo.full_name,
|
||||
author_association: pr.data.author_association,
|
||||
};
|
||||
};
|
||||
|
||||
const createReaction = async (context, github) => {
|
||||
const { owner, repo } = context.repo;
|
||||
const { id: comment_id } = context.payload.comment;
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id,
|
||||
content: "rocket",
|
||||
});
|
||||
};
|
||||
|
||||
const createStatus = async (context, github, core) => {
|
||||
const { head_sha, head_ref, repository } = await getPullInfo(context, github);
|
||||
if (repository === "mlflow/mlflow" && head_ref === "master") {
|
||||
core.setFailed("Running autoformat bot against master branch of mlflow/mlflow is not allowed.");
|
||||
}
|
||||
await createCommitStatus(context, github, head_sha, "pending");
|
||||
};
|
||||
|
||||
const updateStatus = async (context, github, sha, needs) => {
|
||||
const failed = Object.values(needs).some(({ result }) => result === "failure");
|
||||
const state = failed ? "failure" : "success";
|
||||
await createCommitStatus(context, github, sha, state);
|
||||
};
|
||||
|
||||
const fetchWorkflowRuns = async ({ context, github, head_sha }) => {
|
||||
const { owner, repo } = context.repo;
|
||||
const SLEEP_DURATION_MS = 5000;
|
||||
const MAX_RETRIES = 5;
|
||||
let prevRuns = [];
|
||||
for (let i = 0; i < MAX_RETRIES; i++) {
|
||||
console.log(`Attempt ${i + 1} to fetch workflow runs`);
|
||||
const runs = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
head_sha,
|
||||
status: "action_required",
|
||||
actor: "mlflow-app[bot]",
|
||||
});
|
||||
|
||||
// If the number of runs has not changed since the last attempt,
|
||||
// we can assume that all the workflow runs have been created.
|
||||
if (runs.length > 0 && runs.length === prevRuns.length) {
|
||||
return runs;
|
||||
}
|
||||
|
||||
prevRuns = runs;
|
||||
await new Promise((resolve) => setTimeout(resolve, SLEEP_DURATION_MS));
|
||||
}
|
||||
return prevRuns;
|
||||
};
|
||||
|
||||
const approveWorkflowRuns = async (context, github, head_sha) => {
|
||||
const { owner, repo } = context.repo;
|
||||
const workflowRuns = await fetchWorkflowRuns({ context, github, head_sha });
|
||||
const approvePromises = workflowRuns.map((run) =>
|
||||
github.rest.actions.approveWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
run_id: run.id,
|
||||
})
|
||||
);
|
||||
const results = await Promise.allSettled(approvePromises);
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected") {
|
||||
console.error(`Failed to approve run: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const VALID_AUTHOR_ASSOCIATIONS = ["owner", "member", "collaborator"];
|
||||
|
||||
const isAllowedUser = ({ author_association, user }) => {
|
||||
return (
|
||||
VALID_AUTHOR_ASSOCIATIONS.includes(author_association.toLowerCase()) ||
|
||||
// Allow Copilot and mlflow-app bot to run this workflow
|
||||
(user &&
|
||||
user.type.toLowerCase() === "bot" &&
|
||||
["copilot", "mlflow-app[bot]"].includes(user.login.toLowerCase()))
|
||||
);
|
||||
};
|
||||
|
||||
const validatePermissions = async (context, github) => {
|
||||
const { comment } = context.payload;
|
||||
const { owner, repo } = context.repo;
|
||||
const pull_number = context.issue.number;
|
||||
|
||||
// Check if commenter is owner/member/collaborator or an allowed bot
|
||||
if (!isAllowedUser({ author_association: comment.author_association, user: comment.user })) {
|
||||
const message = `This workflow can only be triggered by a repository owner, member, or collaborator. @${comment.user.login} (${comment.author_association}) does not have sufficient permissions.`;
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pull_number,
|
||||
body: `❌ **Autoformat failed**: ${message}`,
|
||||
});
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number });
|
||||
const prAuthorAssociation = pr.author_association.toLowerCase();
|
||||
|
||||
// If PR author is not a trusted user, this is a community PR
|
||||
if (!isAllowedUser({ author_association: prAuthorAssociation, user: pr.user })) {
|
||||
// Community PR — require at least one approved review
|
||||
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number,
|
||||
});
|
||||
|
||||
const hasApproval = reviews.some((review) => review.state === "APPROVED");
|
||||
|
||||
if (!hasApproval) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pull_number,
|
||||
body: `❌ **Autoformat failed**: This workflow requires an approved review before running on community PRs. Please approve the PR and comment \`/autoformat\` again.`,
|
||||
});
|
||||
throw new Error("This workflow requires an approved review before running on community PRs.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkMaintainerAccess = async (context, github) => {
|
||||
const { owner, repo } = context.repo;
|
||||
const pull_number = context.issue.number;
|
||||
const { runId } = context;
|
||||
const pr = await github.rest.pulls.get({ owner, repo, pull_number });
|
||||
|
||||
// Skip maintainer access check for copilot bot PRs
|
||||
// Copilot bot creates PRs that are owned by the repository and don't need the same permission model
|
||||
if (
|
||||
pr.data.user?.type?.toLowerCase() === "bot" &&
|
||||
pr.data.user?.login?.toLowerCase() === "copilot"
|
||||
) {
|
||||
console.log(`Skipping maintainer access check for copilot bot PR #${pull_number}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const isForkPR = pr.data.head.repo.full_name !== pr.data.base.repo.full_name;
|
||||
if (isForkPR && !pr.data.maintainer_can_modify) {
|
||||
const workflowRunUrl = `https://github.com/${owner}/${repo}/actions/runs/${runId}`;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pull_number,
|
||||
body: `❌ **Autoformat failed**: The "Allow edits and access to secrets by maintainers" checkbox must be checked for autoformat to work properly.
|
||||
|
||||
Please:
|
||||
1. Check the "Allow edits and access to secrets by maintainers" checkbox on this pull request
|
||||
2. Comment \`/autoformat\` again
|
||||
|
||||
This permission is required for the autoformat bot to push changes to your branch.
|
||||
|
||||
**Details:** [View workflow run](${workflowRunUrl})`,
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
'The "Allow edits and access to secrets by maintainers" checkbox must be checked for autoformat to work properly.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
shouldAutoformat,
|
||||
getPullInfo,
|
||||
createReaction,
|
||||
createStatus,
|
||||
updateStatus,
|
||||
approveWorkflowRuns,
|
||||
checkMaintainerAccess,
|
||||
validatePermissions,
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
# Autoformat
|
||||
|
||||
## Testing
|
||||
|
||||
1. Checkout a new branch and make changes.
|
||||
1. Push the branch to your fork (https://github.com/{your_username}/mlflow).
|
||||
1. Switch the default branch of your fork to the branch you just pushed.
|
||||
1. Create a GitHub token.
|
||||
1. Create a new Actions secret with the name `MLFLOW_AUTOMATION_TOKEN` and put the token value.
|
||||
1. Checkout another new branch and run the following commands to make dummy changes.
|
||||
|
||||
```shell
|
||||
# python
|
||||
echo "" >> setup.py
|
||||
# js
|
||||
echo "" >> mlflow/server/js/src/experiment-tracking/components/App.js
|
||||
# protos
|
||||
echo "message Foo {}" >> mlflow/protos/service.proto
|
||||
```
|
||||
|
||||
1. Create a PR from the branch containing the dummy changes in your fork.
|
||||
1. Comment `/autoformat` on the PR and ensure the workflow runs successfully.
|
||||
The workflow status can be checked at https://github.com/{your_username}/mlflow/actions/workflows/autoformat.yml.
|
||||
1. Delete the GitHub token and reset the default branch.
|
||||
@@ -0,0 +1,324 @@
|
||||
# See .github/workflows/autoformat.md for instructions on how to test this workflow.
|
||||
|
||||
name: Autoformat
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
check-comment:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/autoformat')
|
||||
permissions:
|
||||
statuses: write # autoformat.createStatus
|
||||
pull-requests: write # autoformat.createReaction on PRs
|
||||
outputs:
|
||||
should_autoformat: ${{ fromJSON(steps.judge.outputs.result).shouldAutoformat }}
|
||||
repository: ${{ fromJSON(steps.judge.outputs.result).repository }}
|
||||
head_ref: ${{ fromJSON(steps.judge.outputs.result).head_ref }}
|
||||
head_sha: ${{ fromJSON(steps.judge.outputs.result).head_sha }}
|
||||
base_ref: ${{ fromJSON(steps.judge.outputs.result).base_ref }}
|
||||
base_sha: ${{ fromJSON(steps.judge.outputs.result).base_sha }}
|
||||
base_repo: ${{ fromJSON(steps.judge.outputs.result).base_repo }}
|
||||
pull_number: ${{ fromJSON(steps.judge.outputs.result).pull_number }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- name: judge
|
||||
id: judge
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
core.debug(JSON.stringify(context, null, 2));
|
||||
const autoformat = require('./.github/workflows/autoformat.js');
|
||||
const { comment } = context.payload;
|
||||
const shouldAutoformat = autoformat.shouldAutoformat(comment);
|
||||
if (shouldAutoformat) {
|
||||
await autoformat.validatePermissions(context, github);
|
||||
await autoformat.createReaction(context, github);
|
||||
await autoformat.createStatus(context, github, core);
|
||||
}
|
||||
const pullInfo = await autoformat.getPullInfo(context, github);
|
||||
return { ...pullInfo, shouldAutoformat };
|
||||
|
||||
- name: Check maintainer access
|
||||
if: fromJSON(steps.judge.outputs.result).shouldAutoformat
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const autoformat = require('./.github/workflows/autoformat.js');
|
||||
await autoformat.checkMaintainerAccess(context, github);
|
||||
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
needs: check-comment
|
||||
if: needs.check-comment.outputs.should_autoformat == 'true'
|
||||
permissions:
|
||||
pull-requests: read # view files modified in PR
|
||||
outputs:
|
||||
reformatted: ${{ steps.patch.outputs.reformatted }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ needs.check-comment.outputs.repository }}
|
||||
ref: ${{ needs.check-comment.outputs.head_ref }}
|
||||
# Set fetch-depth to merge the base branch
|
||||
fetch-depth: 100
|
||||
- name: Verify head SHA
|
||||
env:
|
||||
EXPECTED_SHA: ${{ needs.check-comment.outputs.head_sha }}
|
||||
run: |
|
||||
actual_sha="$(git rev-parse HEAD)"
|
||||
if [[ "$actual_sha" != "$EXPECTED_SHA" ]]; then
|
||||
echo "::error::HEAD has changed since the /autoformat comment (expected $EXPECTED_SHA, got $actual_sha). Please re-comment /autoformat."
|
||||
exit 1
|
||||
fi
|
||||
- name: Check diff
|
||||
id: diff
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
PULL_NUMBER: ${{ needs.check-comment.outputs.pull_number }}
|
||||
run: |
|
||||
changed_files="$(gh pr view --repo $REPO $PULL_NUMBER --json files --jq '.files.[].path')"
|
||||
protos=$([[ -z $(echo "$changed_files" | grep '^\(mlflow/protos\|tests/protos\)') ]] && echo "false" || echo "true")
|
||||
js=$([[ -z $(echo "$changed_files" | grep '^mlflow/server/js') ]] && echo "false" || echo "true")
|
||||
docs=$([[ -z $(echo "$changed_files" | grep '^docs/') ]] && echo "false" || echo "true")
|
||||
r=$([[ -z $(echo "$changed_files" | grep '^mlflow/R/mlflow') ]] && echo "false" || echo "true")
|
||||
db=$([[ -z $(echo "$changed_files" | grep '^mlflow/store/db_migrations/') ]] && echo "false" || echo "true")
|
||||
api=$([[ -z $(echo "$changed_files" | grep -E '(^mlflow/.*\.py$|^docs/api_reference/.*\.rst$)') ]] && echo "false" || echo "true")
|
||||
echo "protos=$protos" >> $GITHUB_OUTPUT
|
||||
echo "js=$js" >> $GITHUB_OUTPUT
|
||||
echo "docs=$docs" >> $GITHUB_OUTPUT
|
||||
echo "r=$r" >> $GITHUB_OUTPUT
|
||||
echo "db=$db" >> $GITHUB_OUTPUT
|
||||
echo "api=$api" >> $GITHUB_OUTPUT
|
||||
# Merge the base branch (which is usually master) to apply formatting using the latest configurations.
|
||||
- name: Merge base branch
|
||||
env:
|
||||
BASE_REPO: ${{ needs.check-comment.outputs.base_repo }}
|
||||
BASE_REF: ${{ needs.check-comment.outputs.base_ref }}
|
||||
run: |
|
||||
# This identity is only used for the temporary merge commit and is not pushed.
|
||||
git config user.name 'name'
|
||||
git config user.email 'email'
|
||||
git remote add base https://github.com/$BASE_REPO.git
|
||||
git fetch base $BASE_REF
|
||||
git merge base/$BASE_REF
|
||||
- uses: ./.github/actions/setup-python
|
||||
# ************************************************************************
|
||||
# pre-commit
|
||||
# ************************************************************************
|
||||
- run: |
|
||||
uv run --only-group lint pre-commit install --install-hooks
|
||||
uv run --only-group lint pre-commit run install-bin -a -v
|
||||
uv run --only-group lint pre-commit run --all-files --color=always || true
|
||||
# ************************************************************************
|
||||
# protos
|
||||
# ************************************************************************
|
||||
- if: steps.diff.outputs.protos == 'true'
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
run: |
|
||||
# Run the script multiple times. The changes generated by the first run
|
||||
# may trigger additional changes, which need to be applied in subsequent runs.
|
||||
for i in {1..3}; do
|
||||
./dev/generate-protos.sh
|
||||
done
|
||||
# ************************************************************************
|
||||
# DB
|
||||
# ************************************************************************
|
||||
- if: steps.diff.outputs.db == 'true'
|
||||
run: |
|
||||
tests/db/update_schemas.sh
|
||||
# ************************************************************************
|
||||
# js
|
||||
# ************************************************************************
|
||||
- if: steps.diff.outputs.js == 'true'
|
||||
uses: ./.github/actions/setup-node
|
||||
- if: steps.diff.outputs.js == 'true'
|
||||
working-directory: mlflow/server/js
|
||||
run: |
|
||||
yarn install --immutable
|
||||
- if: steps.diff.outputs.js == 'true'
|
||||
working-directory: mlflow/server/js
|
||||
run: |
|
||||
yarn lint:fix
|
||||
yarn prettier:fix
|
||||
- if: steps.diff.outputs.js == 'true'
|
||||
working-directory: mlflow/server/js
|
||||
run: |
|
||||
yarn i18n
|
||||
- if: steps.diff.outputs.docs == 'true'
|
||||
working-directory: docs
|
||||
run: |
|
||||
npm ci
|
||||
- if: steps.diff.outputs.docs == 'true'
|
||||
working-directory: docs
|
||||
run: |
|
||||
npm run prettier:fix
|
||||
# ************************************************************************
|
||||
# R
|
||||
# ************************************************************************
|
||||
- if: steps.diff.outputs.r == 'true'
|
||||
working-directory: docs/api_reference
|
||||
run: |
|
||||
./build-rdoc.sh
|
||||
# ************************************************************************
|
||||
# API Reference
|
||||
# ************************************************************************
|
||||
- if: steps.diff.outputs.api == 'true'
|
||||
run: |
|
||||
uv sync --group docs --extra gateway
|
||||
uv pip install -r requirements/torch.txt
|
||||
uv run --directory docs/api_reference make dummy
|
||||
# ************************************************************************
|
||||
# Upload patch
|
||||
# ************************************************************************
|
||||
- name: Create patch
|
||||
id: patch
|
||||
env:
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
git add -N .
|
||||
git diff > $RUN_ID.diff
|
||||
reformatted=$([[ -s $RUN_ID.diff ]] && echo "true" || echo "false")
|
||||
echo "reformatted=$reformatted" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload patch
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: ${{ github.run_id }}.diff
|
||||
path: ${{ github.run_id }}.diff
|
||||
retention-days: 1
|
||||
if-no-files-found: ignore
|
||||
|
||||
push:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
needs: [check-comment, format]
|
||||
if: needs.format.outputs.reformatted == 'true'
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
head_sha: ${{ steps.push.outputs.head_sha }}
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
# See https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps
|
||||
# for how to rotate the private key
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
permission-contents: write
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: true
|
||||
repository: ${{ needs.check-comment.outputs.repository }}
|
||||
ref: ${{ needs.check-comment.outputs.head_ref }}
|
||||
# Set fetch-depth to merge the base branch
|
||||
fetch-depth: 100
|
||||
# As reported in https://github.com/orgs/community/discussions/25702, if an action pushes
|
||||
# code using `GITHUB_TOKEN`, that won't trigger new workflow runs on the PR.
|
||||
# A personal access token is required to trigger new workflow runs.
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Verify head SHA
|
||||
env:
|
||||
EXPECTED_SHA: ${{ needs.check-comment.outputs.head_sha }}
|
||||
run: |
|
||||
actual_sha="$(git rev-parse HEAD)"
|
||||
if [[ "$actual_sha" != "$EXPECTED_SHA" ]]; then
|
||||
echo "::error::HEAD has changed since the /autoformat comment (expected $EXPECTED_SHA, got $actual_sha). Please re-comment /autoformat."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Configure git
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ needs.check-comment.outputs.pull_number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
AUTHOR=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json author --jq '.author')
|
||||
IS_BOT=$(echo "$AUTHOR" | jq -r '.is_bot')
|
||||
if [ "$IS_BOT" = "false" ]; then
|
||||
LOGIN=$(echo "$AUTHOR" | jq -r '.login')
|
||||
else
|
||||
LOGIN="mlflow-app[bot]"
|
||||
fi
|
||||
ID=$(gh api "users/$LOGIN" --jq '.id')
|
||||
git config user.name "$LOGIN"
|
||||
git config user.email "${ID}+${LOGIN}@users.noreply.github.com"
|
||||
|
||||
- name: Merge base branch
|
||||
env:
|
||||
BASE_REPO: ${{ needs.check-comment.outputs.base_repo }}
|
||||
BASE_REF: ${{ needs.check-comment.outputs.base_ref }}
|
||||
run: |
|
||||
git remote add base https://github.com/${BASE_REPO}.git
|
||||
git fetch base $BASE_REF
|
||||
git merge base/${BASE_REF}
|
||||
|
||||
- name: Download patch
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ${{ github.run_id }}.diff
|
||||
path: /tmp
|
||||
|
||||
- name: Apply patch and push
|
||||
id: push
|
||||
env:
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
git apply /tmp/${RUN_ID}.diff
|
||||
git add .
|
||||
git commit -sm "Autoformat: https://github.com/${REPOSITORY}/actions/runs/${RUN_ID}"
|
||||
echo "head_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
git push
|
||||
|
||||
update-status:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
needs: [check-comment, format, push]
|
||||
if: always() && needs.check-comment.outputs.should_autoformat == 'true'
|
||||
permissions:
|
||||
statuses: write # To update check statuses
|
||||
actions: write # To approve workflow runs
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- name: Update status
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
env:
|
||||
NEEDS_JSON: ${{ toJson(needs) }}
|
||||
HEAD_SHA: ${{ needs.check-comment.outputs.head_sha }}
|
||||
PUSH_HEAD_SHA: ${{ needs.push.outputs.head_sha }}
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const needs = JSON.parse(process.env.NEEDS_JSON);
|
||||
const head_sha = process.env.HEAD_SHA;
|
||||
const autoformat = require('./.github/workflows/autoformat.js');
|
||||
const push_head_sha = process.env.PUSH_HEAD_SHA;
|
||||
if (push_head_sha) {
|
||||
await autoformat.approveWorkflowRuns(context, github, push_head_sha);
|
||||
}
|
||||
await autoformat.updateStatus(context, github, head_sha, needs);
|
||||
@@ -0,0 +1,194 @@
|
||||
# Build a wheel for MLflow and upload it as an artifact.
|
||||
name: build-wheel
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- branch-[0-9]+.[0-9]+
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "dev/clint/**"
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/preview-docs.yml"
|
||||
- "mlflow/server/js/**"
|
||||
- ".github/workflows/js.yml"
|
||||
- ".claude/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "The branch, tag or SHA to build the wheel from."
|
||||
required: true
|
||||
default: "master"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
type: ["dev", "skinny", "tracing"]
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/setup-python
|
||||
- if: matrix.type == 'dev'
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
- name: Build UI
|
||||
if: matrix.type == 'dev'
|
||||
working-directory: mlflow/server/js
|
||||
run: |
|
||||
yarn install --immutable
|
||||
yarn build
|
||||
|
||||
- name: Create placeholder UI
|
||||
if: matrix.type != 'dev'
|
||||
run: |
|
||||
mkdir -p mlflow/server/js/build
|
||||
echo "<html></html>" > mlflow/server/js/build/index.html
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv pip install --system build setuptools twine wheel
|
||||
|
||||
- name: Build distribution files
|
||||
id: build-dist
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
MATRIX_TYPE: ${{ matrix.type }}
|
||||
run: |
|
||||
# if workflow_dispatch is triggered, use the specified ref
|
||||
if [ "$EVENT_NAME" == "workflow_dispatch" ]; then
|
||||
SHA_OPT="--sha $(git rev-parse HEAD)"
|
||||
else
|
||||
SHA_OPT=""
|
||||
fi
|
||||
|
||||
python dev/build.py --package-type "$MATRIX_TYPE" $SHA_OPT
|
||||
|
||||
# List distribution files and check their file sizes
|
||||
ls -lh dist
|
||||
|
||||
# Set step outputs
|
||||
sdist_path=$(find dist -type f -name "*.tar.gz")
|
||||
wheel_path=$(find dist -type f -name "*.whl")
|
||||
wheel_name=$(basename $wheel_path)
|
||||
wheel_size=$(stat -c %s $wheel_path)
|
||||
echo "sdist-path=${sdist_path}" >> $GITHUB_OUTPUT
|
||||
echo "wheel-path=${wheel_path}" >> $GITHUB_OUTPUT
|
||||
echo "wheel-name=${wheel_name}" >> $GITHUB_OUTPUT
|
||||
echo "wheel-size=${wheel_size}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: List files in source distribution
|
||||
env:
|
||||
SDIST_PATH: ${{ steps.build-dist.outputs.sdist-path }}
|
||||
run: |
|
||||
tar -tf $SDIST_PATH
|
||||
|
||||
- name: List files in binary distribution
|
||||
env:
|
||||
WHEEL_PATH: ${{ steps.build-dist.outputs.wheel-path }}
|
||||
run: |
|
||||
unzip -l $WHEEL_PATH
|
||||
|
||||
- name: Compare files in source and binary distributions
|
||||
env:
|
||||
SDIST_PATH: ${{ steps.build-dist.outputs.sdist-path }}
|
||||
WHEEL_PATH: ${{ steps.build-dist.outputs.wheel-path }}
|
||||
run: |
|
||||
tar -tzf $SDIST_PATH | grep -v '/$' | cut -d'/' -f2- | sort > /tmp/source.txt
|
||||
zipinfo -1 $WHEEL_PATH | sort > /tmp/wheel.txt
|
||||
diff /tmp/source.txt /tmp/wheel.txt || true
|
||||
|
||||
- name: Run twine check
|
||||
env:
|
||||
WHEEL_PATH: ${{ steps.build-dist.outputs.wheel-path }}
|
||||
run: |
|
||||
twine check --strict $WHEEL_PATH
|
||||
|
||||
- name: Test installation from tarball
|
||||
env:
|
||||
SDIST_PATH: ${{ steps.build-dist.outputs.sdist-path }}
|
||||
run: |
|
||||
uv pip install --system $SDIST_PATH
|
||||
python -c "import mlflow; print(mlflow.__version__)"
|
||||
python -c "from mlflow import *"
|
||||
|
||||
- name: Test installation from wheel
|
||||
env:
|
||||
WHEEL_PATH: ${{ steps.build-dist.outputs.wheel-path }}
|
||||
run: |
|
||||
uv pip install --system --force-reinstall $WHEEL_PATH
|
||||
python -c "import mlflow; print(mlflow.__version__)"
|
||||
python -c "from mlflow import *"
|
||||
|
||||
- name: Test installation from GitHub
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
REF: ${{ github.ref }}
|
||||
MATRIX_TYPE: ${{ matrix.type }}
|
||||
run: |
|
||||
if [ "$MATRIX_TYPE" == "skinny" ]; then
|
||||
URL="git+https://github.com/${REPO}.git@${REF}#subdirectory=libs/skinny"
|
||||
elif [ "$MATRIX_TYPE" == "tracing" ]; then
|
||||
URL="git+https://github.com/${REPO}.git@${REF}#subdirectory=libs/tracing"
|
||||
else
|
||||
URL="git+https://github.com/${REPO}.git@${REF}"
|
||||
fi
|
||||
|
||||
uv run --isolated --no-project --with $URL python -I -c 'import mlflow; print(mlflow.__version__)'
|
||||
|
||||
- name: Test dev/install-skinny.sh
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
dev/install-skinny.sh pull/$PR_NUMBER/merge
|
||||
|
||||
# Anyone with read access can download the uploaded wheel on GitHub.
|
||||
- name: Upload wheel
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
id: upload-wheel
|
||||
with:
|
||||
name: ${{ steps.build-dist.outputs.wheel-name }}
|
||||
path: ${{ steps.build-dist.outputs.wheel-path }}
|
||||
retention-days: 7
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Generate summary
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
env:
|
||||
ARTIFACT_URL: ${{ steps.upload-wheel.outputs.artifact-url }}
|
||||
run: |
|
||||
echo "### Download URL" >> $GITHUB_STEP_SUMMARY
|
||||
echo "$ARTIFACT_URL" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Notes" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- The artifact will be deleted after 7 days." >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Unzip the downloaded artifact to get the wheel." >> $GITHUB_STEP_SUMMARY
|
||||
@@ -0,0 +1,32 @@
|
||||
# Cancel workflow runs associated with a pull request when it is closed or merged.
|
||||
name: Cancel
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- closed
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
cancel:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: write # to cancel workflow runs
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh run list --repo "$REPO" --commit "$HEAD_SHA" --event pull_request \
|
||||
--json databaseId,status,name \
|
||||
--jq '.[] | select(.status != "completed" and .name != "release-note") | .databaseId' |
|
||||
while read -r run_id; do
|
||||
gh run cancel "$run_id" --repo "$REPO" || true
|
||||
done
|
||||
@@ -0,0 +1,42 @@
|
||||
name: cherry-picks-warn
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
branches:
|
||||
- branch-[0-9]+.[0-9]+
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: write # to post a comment on the PR
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
gh pr comment "$PR" --repo "$REPO" --body '# ⚠️ Important: Cherry-Pick Merge Instructions
|
||||
|
||||
**If you are cherry-picking commits to a release branch, "Rebase and merge" must be used when merging this PR, NOT "Squash and merge".**
|
||||
|
||||
### Why "Squash and merge" causes problems:
|
||||
|
||||
- It makes reverting individual commits impossible
|
||||
- It removes the association between original and cherry-picked commits
|
||||
- It makes it difficult to track which commits have been cherry-picked
|
||||
- It causes incorrect results in:
|
||||
- [`update-release-labels.yml`](.github/workflows/update-release-labels.yml)
|
||||
- [`update_changelog.py`](dev/update_changelog.py)
|
||||
- [`check_patch_prs.py`](dev/check_patch_prs.py)
|
||||
|
||||
If "Rebase and merge" is disabled, follow [Configuring commit rebasing for pull requests](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/configuring-pull-request-merges/configuring-commit-rebasing-for-pull-requests) to enable it.'
|
||||
@@ -0,0 +1,36 @@
|
||||
name: close-security-issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
close:
|
||||
if: github.repository == 'mlflow/mlflow'
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
title_lower=$(echo "$ISSUE_TITLE" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
if echo "$title_lower" | grep -qE "security\s+vulnerability"; then
|
||||
gh issue close "$ISSUE_NUMBER" --repo "$REPO" --comment "$(cat <<'EOF'
|
||||
This issue has been automatically closed because it appears to report a security vulnerability.
|
||||
|
||||
Please report security vulnerabilities through [GitHub's private vulnerability reporting](https://github.com/mlflow/mlflow/security/advisories/new) instead. See our [Security Policy](https://github.com/mlflow/mlflow/blob/master/SECURITY.md) for more details.
|
||||
EOF
|
||||
)"
|
||||
fi
|
||||
@@ -0,0 +1,37 @@
|
||||
const { getCloseReason } = require("./auto-close-pr.js");
|
||||
|
||||
module.exports = async ({ context, github }) => {
|
||||
const closeReason = await getCloseReason({ github, context });
|
||||
if (closeReason) {
|
||||
console.log("PR will be auto-closed. Skipping labeling.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const number = context.payload.pull_request.number;
|
||||
|
||||
const result = await github.graphql(
|
||||
`query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ owner, repo, number }
|
||||
);
|
||||
|
||||
const issues = result.repository.pullRequest.closingIssuesReferences.nodes;
|
||||
for (const { number: issue_number } of issues) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
labels: ["has-closing-pr"],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
name: Closing PR
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
closing-pr:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
pull-requests: read # closing-pr.js reads the PR body
|
||||
issues: write # closing-pr.js labels issues
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/workflows/closing-pr.js`
|
||||
);
|
||||
await script({ context, github });
|
||||
@@ -0,0 +1,58 @@
|
||||
name: Label Community PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
label-community:
|
||||
if: github.repository == 'mlflow/mlflow'
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
|
||||
ASSOCIATION: ${{ github.event.pull_request.author_association }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
# Skip bot PRs
|
||||
if [[ "$AUTHOR_TYPE" == "Bot" ]]; then
|
||||
echo "Bot PR (type: $AUTHOR_TYPE), skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Skip internal PRs based on author association
|
||||
if [[ "$ASSOCIATION" =~ ^(MEMBER|COLLABORATOR|OWNER)$ ]]; then
|
||||
echo "Internal PR (association: $ASSOCIATION), skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check user profile for Databricks affiliation
|
||||
PROFILE=$(gh api "users/$AUTHOR" --jq '[.company // "", .email // ""] | join("\n")')
|
||||
if echo "$PROFILE" | grep -iq 'databricks'; then
|
||||
echo "Internal PR (profile contains 'databricks'), skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check commit author emails for @databricks.com
|
||||
if gh api "repos/$REPO/pulls/$PR_NUMBER/commits" \
|
||||
--jq '.[].commit.author.email' | grep -iq '@databricks\.com'; then
|
||||
echo "Internal PR (commit email ends with @databricks.com), skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Add community label
|
||||
gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "community"
|
||||
echo "Added 'community' label to PR #$PR_NUMBER"
|
||||
@@ -0,0 +1,34 @@
|
||||
name: copilot-setup-steps
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-node
|
||||
- uses: ./.github/actions/setup-python
|
||||
- uses: ./.github/actions/setup-java
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
uv sync --only-group lint
|
||||
- name: pre-commit setup
|
||||
run: |
|
||||
uv run --only-group lint pre-commit install --install-hooks
|
||||
uv run --only-group lint pre-commit run install-bin -a -v
|
||||
@@ -0,0 +1,53 @@
|
||||
name: Cross version test runner
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
run:
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
if: >
|
||||
github.event.issue.pull_request &&
|
||||
startsWith(github.event.comment.body, '/cvt') &&
|
||||
contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.issue.author_association) &&
|
||||
contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: write
|
||||
steps:
|
||||
- name: Dispatch cross-version tests
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.issue.number }}
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
run: |
|
||||
flavors=$(printf '%s' "$COMMENT_BODY" | sed -nE '1 s|^/cvt[[:space:]]+(.+)$|\1|p')
|
||||
if [ -z "$flavors" ]; then
|
||||
echo "No flavors specified; skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
pr_json=$(gh api "repos/$GH_REPO/pulls/$PR_NUMBER")
|
||||
base_ref=$(jq -r .base.ref <<< "$pr_json")
|
||||
merge_sha=$(jq -r .merge_commit_sha <<< "$pr_json")
|
||||
|
||||
payload=$(jq -n \
|
||||
--arg ref "$base_ref" \
|
||||
--arg merge_sha "$merge_sha" \
|
||||
--arg flavors "$flavors" \
|
||||
--arg repo_full "$GH_REPO" \
|
||||
'{ref: $ref, return_run_details: true, inputs: {repository: $repo_full, ref: $merge_sha, flavors: $flavors}}')
|
||||
|
||||
run_url=$(printf '%s' "$payload" | gh api -X POST \
|
||||
"repos/$GH_REPO/actions/workflows/cross-version-tests.yml/dispatches" \
|
||||
--input - --jq .html_url)
|
||||
|
||||
gh pr comment "$PR_NUMBER" --body "Cross-version test run started: $run_url"
|
||||
@@ -0,0 +1,196 @@
|
||||
# Cross version testing
|
||||
|
||||
## What is cross version testing?
|
||||
|
||||
Cross version testing is a testing strategy to ensure ML integrations in MLflow such as
|
||||
`mlflow.sklearn` work properly with their associated packages across various versions.
|
||||
|
||||
## Key files
|
||||
|
||||
| File (relative path from the root) | Role |
|
||||
| :---------------------------------------------- | :---------------------------------------------------------------------- |
|
||||
| [`mlflow/ml-package-versions.yml`][] | Define which versions to test for each ML package. |
|
||||
| [`flavors matrix`][flavors-cli] | Generate a test matrix from `ml-package-versions.yml` (`dev/flavors/`). |
|
||||
| [`flavors update`][flavors-cli] | Update `ml-package-versions.yml` when releasing a new version. |
|
||||
| [`.github/workflows/cross-version-tests.yml`][] | Define a Github Actions workflow for cross version testing. |
|
||||
|
||||
[`mlflow/ml-package-versions.yml`]: ../../mlflow/ml-package-versions.yml
|
||||
[flavors-cli]: ../../dev/flavors/
|
||||
[`.github/workflows/cross-version-tests.yml`]: ./cross-version-tests.yml
|
||||
|
||||
## Configuration keys in `ml-package-versions.yml`
|
||||
|
||||
```yml
|
||||
# Note this is just an example and not the actual sklearn configuration.
|
||||
|
||||
# The top-level key specifies the integration name.
|
||||
sklearn:
|
||||
package_info:
|
||||
# [Required] `pip_release` specifies the package this integration depends on.
|
||||
pip_release: "scikit-learn"
|
||||
|
||||
# [Optional] `install_dev` specifies a set of commands to install the dev version of the package.
|
||||
# For example, the command below builds a wheel from the latest main branch of
|
||||
# the scikit-learn repository and installs it.
|
||||
#
|
||||
# The aim of testing the dev version is to spot issues as early as possible before they get
|
||||
# piled up, and fix them incrementally rather than fixing them at once when the package
|
||||
# releases a new version.
|
||||
install_dev: |
|
||||
pip install git+https://github.com/scikit-learn/scikit-learn.git
|
||||
|
||||
# [At least one of `models` and `autologging` must be specified]
|
||||
# `models` specifies the configuration for model serialization and serving tests.
|
||||
# `autologging` specifies the configuration for autologging tests.
|
||||
models or autologging:
|
||||
# [Optional] `requirements` specifies additional pip requirements required for running tests.
|
||||
# For example, '">= 0.24.0": ["xgboost"]' is interpreted as 'if the version of scikit-learn
|
||||
# to install is newer than or equal to 0.24.0, install xgboost'.
|
||||
requirements:
|
||||
">= 0.24.0": ["xgboost"]
|
||||
|
||||
# [Required] `minimum` specifies the minimum supported version for the latest release of MLflow.
|
||||
minimum: "0.20.3"
|
||||
|
||||
# [Required] `maximum` specifies the maximum supported version for the latest release of MLflow.
|
||||
maximum: "1.0"
|
||||
|
||||
# [Optional] `unsupported` specifies a list of versions that should NOT be supported due to
|
||||
# unacceptable issues or bugs.
|
||||
unsupported: ["0.21.3"]
|
||||
|
||||
# [Required] `run` specifies a set of commands to run tests.
|
||||
run: |
|
||||
pytest tests/sklearn/test_sklearn_model_export.py
|
||||
```
|
||||
|
||||
## How do we determine which versions to test?
|
||||
|
||||
We determine which versions to test based on the following rules:
|
||||
|
||||
1. Only test [final][] (e.g. `1.0.0`) and [post][] (`1.0.0.post0`) releases.
|
||||
2. Only test the latest micro version in each minor version.
|
||||
For example, if `1.0.0`, `1.0.1`, and `1.0.2` are available, we only test `1.0.2`.
|
||||
3. The `maximum` version defines the maximum **major** version to test.
|
||||
For example, if the value of `maximum` is `1.0.0`, we test `1.1.0` (if available) but not `2.0.0`.
|
||||
4. Always test the `minimum` version.
|
||||
|
||||
[final]: https://www.python.org/dev/peps/pep-0440/#final-releases
|
||||
[post]: https://www.python.org/dev/peps/pep-0440/#post-releases
|
||||
|
||||
The table below describes which `scikit-learn` versions to test for the example configuration in
|
||||
the previous section:
|
||||
|
||||
| Version | Tested | Comment |
|
||||
| :------------ | :----- | -------------------------------------------------- |
|
||||
| 0.20.3 | ✅ | The value of `minimum` |
|
||||
| 0.20.4 | ✅ | The latest micro version of `0.20` |
|
||||
| 0.21rc2 | | |
|
||||
| 0.21.0 | | |
|
||||
| 0.21.1 | | |
|
||||
| 0.21.2 | ✅ | The latest micro version of `0.21` without`0.21.3` |
|
||||
| 0.21.3 | | Excluded by `unsupported` |
|
||||
| 0.22rc2.post1 | | |
|
||||
| 0.22rc3 | | |
|
||||
| 0.22 | | |
|
||||
| 0.22.1 | | |
|
||||
| 0.22.2 | | |
|
||||
| 0.22.2.post1 | ✅ | The latest micro version of `0.22` |
|
||||
| 0.23.0rc1 | | |
|
||||
| 0.23.0 | | |
|
||||
| 0.23.1 | | |
|
||||
| 0.23.2 | ✅ | The latest micro version of `0.23` |
|
||||
| 0.24.dev0 | | |
|
||||
| 0.24.0rc1 | | |
|
||||
| 0.24.0 | | |
|
||||
| 0.24.1 | | |
|
||||
| 0.24.2 | ✅ | The latest micro version of `0.24` |
|
||||
| 1.0rc1 | | |
|
||||
| 1.0rc2 | | |
|
||||
| 1.0 | | The value of `maximum` |
|
||||
| 1.0.1 | ✅ | The latest micro version of `1.0` |
|
||||
| 1.1.dev | ✅ | The version installed by `install_dev` |
|
||||
|
||||
## Why do we run tests against development versions?
|
||||
|
||||
In cross-version testing, we run daily tests against both publicly available and pre-release
|
||||
development versions for all dependent libraries that are used by MLflow.
|
||||
This section explains why.
|
||||
|
||||
### Without dev version test
|
||||
|
||||
First, let's take a look at what would happen **without** dev version test.
|
||||
|
||||
```
|
||||
|
|
||||
├─ XGBoost merges a change on the master branch that breaks MLflow's XGBoost integration.
|
||||
|
|
||||
├─ MLflow 1.20.0 release date
|
||||
|
|
||||
├─ XGBoost 1.5.0 release date
|
||||
├─ ❌ We notice the change here and might need to make a patch release if it's critical.
|
||||
|
|
||||
v
|
||||
time
|
||||
```
|
||||
|
||||
- We didn't notice the change until after XGBoost 1.5.0 was released.
|
||||
- MLflow 1.20.0 doesn't work with XGBoost 1.5.0.
|
||||
|
||||
### With dev version test
|
||||
|
||||
Then, let's take a look at what would happen **with** dev version test.
|
||||
|
||||
```
|
||||
|
|
||||
├─ XGBoost merges a change on the master branch that breaks MLflow's XGBoost integration.
|
||||
├─ ✅ Tests for the XGBoost integration fail -> We can notice the change and apply a fix for it.
|
||||
|
|
||||
├─ MLflow 1.20.0 release date
|
||||
|
|
||||
├─ XGBoost 1.5.0 release date
|
||||
|
|
||||
v
|
||||
time
|
||||
```
|
||||
|
||||
- We can notice the change **before XGBoost 1.5.0 is released** and apply a fix for it **before releasing MLflow 1.20.0**.
|
||||
- MLflow 1.20.0 works with XGBoost 1.5.0.
|
||||
|
||||
## When do we run cross version tests?
|
||||
|
||||
1. Daily at 7:00 UTC using a cron scheduler.
|
||||
[README on the repository root](../../README.md) has a badge ([![badge-img][]][badge-target]) that indicates the status of the most recent cron run.
|
||||
2. When a PR that affects the ML integrations is created. Note we only run tests relevant to
|
||||
the affected ML integrations. For example, a PR that affects files in `mlflow/sklearn` triggers
|
||||
cross version tests for `sklearn`.
|
||||
|
||||
[badge-img]: https://github.com/mlflow/mlflow/workflows/Cross%20version%20tests/badge.svg?event=schedule
|
||||
[badge-target]: https://github.com/mlflow/mlflow/actions?query=workflow%3ACross%2Bversion%2Btests+event%3Aschedule
|
||||
|
||||
## How to run cross version test for dev versions on a pull request
|
||||
|
||||
By default, cross version tests for dev versions are disabled on a pull request.
|
||||
To enable them, the following steps are required.
|
||||
|
||||
1. Click `Labels` in the right sidebar.
|
||||
2. Click the `enable-dev-tests` label and make sure it's applied on the pull request.
|
||||
3. Push a new commit or re-run the `cross-version-tests` workflow.
|
||||
|
||||
See also:
|
||||
|
||||
- [GitHub Docs - Applying a label](https://docs.github.com/en/issues/using-labels-and-milestones-to-track-work/managing-labels#applying-a-label)
|
||||
- [GitHub Docs - Re-running workflows and jobs](https://docs.github.com/en/actions/managing-workflow-runs/re-running-workflows-and-jobs)
|
||||
|
||||
## How to run cross version tests manually
|
||||
|
||||
The `cross-version-tests.yml` workflow can be run manually without creating a pull request.
|
||||
|
||||
1. Open https://github.com/mlflow/mlflow/actions/workflows/cross-version-tests.yml.
|
||||
2. Click `Run workflow`.
|
||||
3. Fill in the input parameters.
|
||||
4. Click `Run workflow` at the bottom of the parameter input form.
|
||||
|
||||
See also:
|
||||
|
||||
- [GitHub Docs - Manually running a workflow](https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow)
|
||||
@@ -0,0 +1,218 @@
|
||||
name: Cross version tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "dev/clint/**"
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/preview-docs.yml"
|
||||
- "mlflow/server/js/**"
|
||||
- ".github/workflows/js.yml"
|
||||
- ".claude/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
repository:
|
||||
description: >
|
||||
[Optional] Repository name with owner. For example, mlflow/mlflow.
|
||||
Defaults to the repository that triggered a workflow.
|
||||
required: false
|
||||
default: ""
|
||||
ref:
|
||||
description: >
|
||||
[Optional] The branch, tag or SHA to checkout. When checking out the repository that
|
||||
triggered a workflow, this defaults to the reference or SHA for that event. Otherwise,
|
||||
uses the default branch.
|
||||
required: false
|
||||
default: ""
|
||||
flavors:
|
||||
description: "[Optional] Comma-separated string specifying which flavors to test (e.g. 'sklearn, xgboost'). If unspecified, all flavors are tested."
|
||||
required: false
|
||||
default: ""
|
||||
versions:
|
||||
description: "[Optional] Comma-separated string specifying which versions to test (e.g. '1.2.3, 4.5.6'). If unspecified, all versions are tested."
|
||||
required: false
|
||||
default: ""
|
||||
schedule:
|
||||
# Run this workflow daily at 13:00 UTC
|
||||
- cron: "0 13 * * *"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
MLFLOW_HOME: ${{ github.workspace }}
|
||||
PIP_EXTRA_INDEX_URL: https://download.pytorch.org/whl/cpu
|
||||
PIP_CONSTRAINT: ${{ github.workspace }}/requirements/constraints.txt
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
set-matrix:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'schedule' && github.repository == 'mlflow/dev') || (github.event_name == 'pull_request' && (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot'))
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
matrix1: ${{ steps.set-matrix.outputs.matrix1 }}
|
||||
matrix2: ${{ steps.set-matrix.outputs.matrix2 }}
|
||||
is_matrix1_empty: ${{ steps.set-matrix.outputs.is_matrix1_empty }}
|
||||
is_matrix2_empty: ${{ steps.set-matrix.outputs.is_matrix2_empty }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event_name == 'schedule' && 'mlflow/mlflow' || github.event.inputs.repository }}
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/setup-python
|
||||
with:
|
||||
pin-micro-version: false
|
||||
- name: Install flavors
|
||||
run: |
|
||||
uv sync --package flavors
|
||||
- name: Check labels
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
id: check-labels
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
if (context.eventName !== "pull_request") {
|
||||
return {
|
||||
enable_dev_tests: true,
|
||||
only_latest: false,
|
||||
};
|
||||
}
|
||||
const labelNames = context.payload.pull_request.labels.map(l => l.name);
|
||||
return {
|
||||
enable_dev_tests: labelNames.includes("enable-dev-tests"),
|
||||
only_latest: labelNames.includes("only-latest"),
|
||||
};
|
||||
- name: Test flavors CLI
|
||||
run: |
|
||||
uv run --no-sync pytest --noconftest dev/flavors/tests
|
||||
- id: set-matrix
|
||||
name: Set matrix
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
ENABLE_DEV_TESTS: ${{ fromJson(steps.check-labels.outputs.result).enable_dev_tests }}
|
||||
ONLY_LATEST: ${{ fromJson(steps.check-labels.outputs.result).only_latest }}
|
||||
FLAVORS: ${{ github.event.inputs.flavors }}
|
||||
VERSIONS: ${{ github.event.inputs.versions }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "pull_request" ]; then
|
||||
REF_VERSIONS_YAML=/tmp/ref-versions.yml
|
||||
git fetch origin "$BASE_REF" --depth=1
|
||||
git show "origin/$BASE_REF:mlflow/ml-package-versions.yml" > "$REF_VERSIONS_YAML"
|
||||
CHANGED_FILES=$(gh pr view "$PR_NUMBER" --json files --jq '.files[].path' | grep -v '^mlflow/server/js' || true)
|
||||
NO_DEV_FLAG=$([ "$ENABLE_DEV_TESTS" == "true" ] && echo "" || echo "--no-dev")
|
||||
ONLY_LATEST_FLAG=$([ "$ONLY_LATEST" == "true" ] && echo "--only-latest" || echo "")
|
||||
uv run --no-sync flavors matrix --ref-versions-yaml "$REF_VERSIONS_YAML" --changed-files "$CHANGED_FILES" $NO_DEV_FLAG $ONLY_LATEST_FLAG
|
||||
elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
uv run --no-sync flavors matrix --flavors "$FLAVORS" --versions "$VERSIONS"
|
||||
else
|
||||
uv run --no-sync flavors matrix
|
||||
fi
|
||||
|
||||
test1:
|
||||
needs: set-matrix
|
||||
if: needs.set-matrix.outputs.is_matrix1_empty == 'false'
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.set-matrix.outputs.matrix1) }}
|
||||
steps: &test-steps
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event_name == 'schedule' && 'mlflow/mlflow' || github.event.inputs.repository }}
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- uses: ./.github/actions/free-disk-space
|
||||
if: matrix.free_disk_space
|
||||
- uses: ./.github/actions/setup-python
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
- uses: ./.github/actions/setup-pyenv
|
||||
- uses: ./.github/actions/setup-java
|
||||
with:
|
||||
java-version: ${{ matrix.java }}
|
||||
- name: Remove constraints
|
||||
env:
|
||||
PACKAGE: ${{ matrix.package }}
|
||||
run: |
|
||||
# Remove any constraints for the current package to prevent installation conflicts
|
||||
sed -i '/^'"$PACKAGE"'/d' requirements/constraints.txt
|
||||
sed -i '/^constraint-dependencies = \[/,/^\]/{ /'"$PACKAGE"'/d }' pyproject.toml
|
||||
|
||||
if ! git diff --exit-code requirements/constraints.txt pyproject.toml; then
|
||||
git diff
|
||||
git config user.name 'mlflow-app[bot]'
|
||||
git config user.email 'mlflow-app[bot]@users.noreply.github.com'
|
||||
git add requirements/constraints.txt pyproject.toml
|
||||
git commit -m "Remove constraints for testing"
|
||||
fi
|
||||
- name: Install mlflow & test dependencies
|
||||
env:
|
||||
MATRIX_CATEGORY: ${{ matrix.category }}
|
||||
run: |
|
||||
# setuptools 82.0.0 removed pkg_resources, this breaking change breaks some packages like transformers
|
||||
uv pip install --system -U wheel "setuptools<82"
|
||||
# For tracing SDK test, install the tracing package from the local path and minimal test dependencies
|
||||
if [[ "$MATRIX_CATEGORY" == "tracing-sdk" ]]; then
|
||||
uv pip install --system libs/tracing
|
||||
uv pip install --system pytest pytest-asyncio pytest-cov
|
||||
# Other two categories of tests (model/autologging)
|
||||
else
|
||||
uv pip install --system .[extras]
|
||||
uv pip install --system -r requirements/test-requirements.txt
|
||||
fi
|
||||
- name: Install ${{ matrix.package }} ${{ matrix.version }}
|
||||
env:
|
||||
MATRIX_INSTALL: ${{ matrix.install }}
|
||||
run: |
|
||||
eval "$MATRIX_INSTALL"
|
||||
- uses: ./.github/actions/show-versions
|
||||
- name: Pre-test
|
||||
if: matrix.pre_test
|
||||
env:
|
||||
MATRIX_PRE_TEST: ${{ matrix.pre_test }}
|
||||
run: |
|
||||
eval "$MATRIX_PRE_TEST"
|
||||
- name: Run tests
|
||||
env:
|
||||
MLFLOW_CONDA_HOME: /usr/share/miniconda
|
||||
SPARK_LOCAL_IP: localhost
|
||||
HF_HUB_ENABLE_HF_TRANSFER: 1
|
||||
MATRIX_RUN: ${{ matrix.run }}
|
||||
run: |
|
||||
eval "$MATRIX_RUN"
|
||||
|
||||
test2:
|
||||
needs: set-matrix
|
||||
if: needs.set-matrix.outputs.is_matrix2_empty == 'false'
|
||||
runs-on: ${{ matrix.runs_on }}
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJson(needs.set-matrix.outputs.matrix2) }}
|
||||
steps: *test-steps
|
||||
@@ -0,0 +1,66 @@
|
||||
name: Dev environment setup
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- branch-[0-9]+.[0-9]+
|
||||
paths:
|
||||
- "dev/dev-env-setup.sh"
|
||||
- "dev/test-dev-env-setup.sh"
|
||||
- ".github/workflows/dev-setup.yml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "dev/dev-env-setup.sh"
|
||||
- "dev/test-dev-env-setup.sh"
|
||||
- ".github/workflows/dev-setup.yml"
|
||||
schedule:
|
||||
- cron: "42 7 * * 0"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
repository:
|
||||
description: >
|
||||
[Optional] Repository name with owner. For example, mlflow/mlflow.
|
||||
Defaults to the repository that triggered a workflow.
|
||||
required: false
|
||||
default: ""
|
||||
ref:
|
||||
description: >
|
||||
[Optional] The branch, tag or SHA to checkout. When checking out the repository that
|
||||
triggered a workflow, this defaults to the reference or SHA for that event. Otherwise,
|
||||
uses the default branch.
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
linux-env-setup:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'schedule' || github.repository == 'mlflow/dev'
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/free-disk-space
|
||||
with:
|
||||
repository: ${{ github.event_name == 'schedule' && 'mlflow/mlflow' || github.event.inputs.repository }}
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- name: Setup environment
|
||||
run: |
|
||||
git config --global user.name "test"
|
||||
git config --global user.email "test@mlflow.org"
|
||||
- name: Run Environment tests
|
||||
run: |
|
||||
TERM=xterm bash ./dev/test-dev-env-setup.sh
|
||||
@@ -0,0 +1,181 @@
|
||||
name: docs
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- mlflow/**
|
||||
- docs/**
|
||||
- .github/workflows/docs.yml
|
||||
- .github/actions/setup-node/**
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
paths:
|
||||
- pyproject.toml
|
||||
- uv.lock
|
||||
- mlflow/**
|
||||
- docs/**
|
||||
- .github/workflows/docs.yml
|
||||
- .github/actions/setup-node/**
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: docs
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
check:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-node
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
- name: Check package-lock.json is up-to-date
|
||||
run: |
|
||||
npm install --package-lock-only --no-audit --no-fund
|
||||
git diff --exit-code package-lock.json || {
|
||||
echo "package-lock.json is out of date. Run 'npm install --package-lock-only' locally and commit the result."
|
||||
exit 1
|
||||
}
|
||||
- name: Run lint
|
||||
run: |
|
||||
npm run eslint
|
||||
- name: Run prettier
|
||||
run: |
|
||||
npm run prettier:check
|
||||
|
||||
build:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-java
|
||||
- uses: ./.github/actions/setup-node
|
||||
- uses: ./.github/actions/setup-python
|
||||
- name: Install dependencies
|
||||
working-directory: .
|
||||
run: |
|
||||
uv sync --group docs --extra gateway
|
||||
uv pip install -r requirements/torch.txt
|
||||
- run: |
|
||||
npm ci
|
||||
- uses: ./.github/actions/show-versions
|
||||
- run: |
|
||||
npm run convert-notebooks
|
||||
- name: Set alias
|
||||
id: alias
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" = "push" ]; then
|
||||
ALIAS="dev"
|
||||
else
|
||||
ALIAS="pr-$PR_NUMBER"
|
||||
fi
|
||||
echo "value=$ALIAS" >> $GITHUB_OUTPUT
|
||||
- name: Build docs
|
||||
env:
|
||||
GTM_ID: "GTM-TEST"
|
||||
API_REFERENCE_PREFIX: https://${{ steps.alias.outputs.value }}--mlflow-docs-preview.netlify.app/docs/
|
||||
run: |
|
||||
npm run build-all -- --no-r --use-npm
|
||||
- name: Check API inventory
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain api_reference/api_inventory.txt)" ]; then
|
||||
echo "The API inventory file 'docs/api_reference/api_inventory.txt' is outdated (see the diff below)."
|
||||
echo "Please update it by running 'make rsthtml' in the 'docs/api_reference' directory, or post a comment '/autoformat' on this PR if you're a maintainer/collaborator."
|
||||
echo "If the new APIs should be marked as experimental, please decorate them with '@experimental'."
|
||||
echo "Diff:"
|
||||
git diff api_reference/api_inventory.txt
|
||||
exit 1
|
||||
fi
|
||||
- name: Check sitemap
|
||||
run: |
|
||||
npm run sitemap -- https://mlflow.org/docs/latest/sitemap.xml ./build/latest/sitemap.xml
|
||||
- name: Move build artifacts
|
||||
run: |
|
||||
mkdir -p /tmp/docs-build/docs
|
||||
mv build/latest /tmp/docs-build/docs/latest
|
||||
|
||||
# Create `docs/versions.json` for the version selector in the API reference
|
||||
VERSION="$(uv version | cut -d' ' -f2)"
|
||||
echo "{\"versions\": [\"$VERSION\"]}" > /tmp/docs-build/docs/versions.json
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: docs-build-${{ github.run_id }}
|
||||
path: /tmp/docs-build
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
|
||||
test-examples:
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: docs/api_reference
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-python
|
||||
- name: Install dependencies
|
||||
working-directory: .
|
||||
run: |
|
||||
uv sync --group docs --extra gateway
|
||||
uv pip install -r requirements/torch.txt
|
||||
- name: Extract examples
|
||||
run: |
|
||||
uv run source/testcode_block.py
|
||||
- name: Run tests
|
||||
run: |
|
||||
uv run pytest .examples
|
||||
|
||||
r:
|
||||
if: (github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot'))
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Build docs
|
||||
working-directory: docs/api_reference
|
||||
run: |
|
||||
./build-rdoc.sh
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
echo "The following files have changed:"
|
||||
git status --porcelain
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,177 @@
|
||||
// Label duplicate community PRs that reference the same issue
|
||||
// Only considers PRs opened in the last 14 days
|
||||
// Keeps the oldest PR and labels newer ones as duplicates
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const DAYS_TO_CONSIDER = 14;
|
||||
const DUPLICATE_LABEL = "duplicate";
|
||||
|
||||
const duplicateMessage = (author, issueNumber, keeperPR) =>
|
||||
`@${author} This PR appears to reference the same issue (#${issueNumber}) as #${keeperPR} (opened earlier). Closing as a duplicate.`;
|
||||
|
||||
// GraphQL query to fetch open PRs created in the last 14 days
|
||||
const QUERY = `
|
||||
query($cursor: String, $searchQuery: String!) {
|
||||
rateLimit { remaining resetAt }
|
||||
search(query: $searchQuery, type: ISSUE, first: 50, after: $cursor) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
... on PullRequest {
|
||||
number
|
||||
createdAt
|
||||
url
|
||||
author { login }
|
||||
authorAssociation
|
||||
labels(first: 20) { nodes { name } }
|
||||
closingIssuesReferences(first: 10) {
|
||||
nodes {
|
||||
number
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const shouldProcessPR = (pr) => {
|
||||
// Only process community PRs (skip maintainer PRs)
|
||||
const memberAssociations = ["MEMBER", "OWNER", "COLLABORATOR"];
|
||||
if (memberAssociations.includes(pr.authorAssociation)) {
|
||||
return false;
|
||||
}
|
||||
// Skip PRs already labeled as duplicate
|
||||
const labels = pr.labels?.nodes?.map((l) => l.name) ?? [];
|
||||
if (labels.includes(DUPLICATE_LABEL)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const getIssueReferences = (pr) => {
|
||||
const references = pr.closingIssuesReferences?.nodes || [];
|
||||
return references.map((node) => node.number);
|
||||
};
|
||||
|
||||
module.exports = async ({ context, github }) => {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
try {
|
||||
// Calculate the date 14 days ago
|
||||
const fourteenDaysAgo = new Date(Date.now() - DAYS_TO_CONSIDER * MS_PER_DAY);
|
||||
const dateString = fourteenDaysAgo.toISOString().slice(0, 10);
|
||||
const searchQuery = `repo:${owner}/${repo} is:pr is:open created:>${dateString}`;
|
||||
|
||||
console.log(`Searching for PRs: ${searchQuery}`);
|
||||
|
||||
let cursor = null;
|
||||
let hasNextPage = true;
|
||||
const allPRs = [];
|
||||
|
||||
// Fetch all open PRs from the last 14 days
|
||||
while (hasNextPage) {
|
||||
const response = await github.graphql(QUERY, { cursor, searchQuery });
|
||||
const { remaining, resetAt } = response.rateLimit;
|
||||
console.log(`Rate limit: ${remaining} remaining, resets at ${resetAt}`);
|
||||
|
||||
const { nodes, pageInfo } = response.search;
|
||||
hasNextPage = pageInfo.hasNextPage;
|
||||
cursor = pageInfo.endCursor;
|
||||
|
||||
allPRs.push(...nodes);
|
||||
}
|
||||
|
||||
console.log(`Found ${allPRs.length} open PRs from the last ${DAYS_TO_CONSIDER} days`);
|
||||
|
||||
// Filter to community PRs only
|
||||
const communityPRs = allPRs.filter(shouldProcessPR);
|
||||
console.log(`${communityPRs.length} are community PRs`);
|
||||
|
||||
// Group PRs by the single issue they reference
|
||||
// Skip PRs that reference multiple issues (ambiguous intent)
|
||||
const prsByIssue = new Map();
|
||||
|
||||
for (const pr of communityPRs) {
|
||||
const issueRefs = getIssueReferences(pr);
|
||||
|
||||
if (issueRefs.length === 0) {
|
||||
// PR doesn't reference any issue, skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
if (issueRefs.length > 1) {
|
||||
// PR references multiple issues, skip it (ambiguous)
|
||||
console.log(
|
||||
`Skipping PR #${pr.number}: references multiple issues (${issueRefs.join(", ")})`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// PR references exactly one issue
|
||||
const issueNumber = issueRefs[0];
|
||||
if (!prsByIssue.has(issueNumber)) {
|
||||
prsByIssue.set(issueNumber, []);
|
||||
}
|
||||
prsByIssue.get(issueNumber).push(pr);
|
||||
}
|
||||
|
||||
console.log(`Found ${prsByIssue.size} issues with associated PRs`);
|
||||
|
||||
// Process each issue that has multiple PRs
|
||||
let closedCount = 0;
|
||||
for (const [issueNumber, prs] of prsByIssue.entries()) {
|
||||
if (prs.length <= 1) {
|
||||
// Only one PR for this issue, no duplicates
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Issue #${issueNumber} has ${prs.length} PRs`);
|
||||
|
||||
// Sort PRs by creation date (oldest first)
|
||||
prs.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
|
||||
|
||||
// Keep the oldest PR, label the rest as duplicates
|
||||
const [keeper, ...duplicates] = prs;
|
||||
console.log(` Keeping PR #${keeper.number} (oldest, created ${keeper.createdAt})`);
|
||||
|
||||
for (const pr of duplicates) {
|
||||
console.log(` Closing PR #${pr.number} as duplicate (created ${pr.createdAt})`);
|
||||
|
||||
// Close first so a failure here leaves the PR open and unlabeled,
|
||||
// letting the next run retry. If we labeled first and then failed
|
||||
// to close, shouldProcessPR would skip the PR forever.
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pr.number,
|
||||
state: "closed",
|
||||
});
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
labels: [DUPLICATE_LABEL],
|
||||
});
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pr.number,
|
||||
body: duplicateMessage(pr.author.login, issueNumber, keeper.number),
|
||||
});
|
||||
|
||||
closedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Closed ${closedCount} duplicate PRs.`);
|
||||
} catch (error) {
|
||||
if (error.status === 429 || error.message?.includes("rate limit")) {
|
||||
console.log(`Rate limit hit. Exiting gracefully.`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Duplicate PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
duplicate-prs:
|
||||
if: github.repository == 'mlflow/mlflow'
|
||||
runs-on: ubuntu-slim
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
.github
|
||||
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const script = require(".github/workflows/duplicate-prs.js");
|
||||
await script({ context, github });
|
||||
@@ -0,0 +1,136 @@
|
||||
name: Examples
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "dev/clint/**"
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/preview-docs.yml"
|
||||
- "mlflow/server/js/**"
|
||||
- ".github/workflows/js.yml"
|
||||
- ".claude/**"
|
||||
schedule:
|
||||
# Run this action daily at 13:00 UTC
|
||||
- cron: "0 13 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
repository:
|
||||
description: >
|
||||
[Optional] Repository name with owner. For example, mlflow/mlflow.
|
||||
Defaults to the repository that triggered a workflow.
|
||||
required: false
|
||||
default: ""
|
||||
ref:
|
||||
description: >
|
||||
[Optional] The branch, tag or SHA to checkout. When checking out the repository that
|
||||
triggered a workflow, this defaults to the reference or SHA for that event. Otherwise,
|
||||
uses the default branch.
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
env:
|
||||
MLFLOW_HOME: ${{ github.workspace }}
|
||||
MLFLOW_CONDA_HOME: /usr/share/miniconda
|
||||
PIP_EXTRA_INDEX_URL: https://download.pytorch.org/whl/cpu
|
||||
PIP_CONSTRAINT: ${{ github.workspace }}/requirements/constraints.txt
|
||||
PYTHONFAULTHANDLER: "1"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
examples:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'schedule' && github.repository == 'mlflow/dev') || (github.event_name == 'pull_request' && (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot'))
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
group: [1, 2]
|
||||
include:
|
||||
- splits: 2
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event_name == 'schedule' && 'mlflow/mlflow' || github.event.inputs.repository }}
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- uses: ./.github/actions/free-disk-space
|
||||
- name: Check diff
|
||||
id: check-diff
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
FORCE_RUN_EXAMPLES: ${{ contains(github.event.pull_request.labels.*.name, 'examples.yml') }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
CHANGED_FILES=$(gh pr view "$PR_NUMBER" --json files --jq '.files[].path' | grep "tests/examples\|examples" || true);
|
||||
if [ "$FORCE_RUN_EXAMPLES" = "true" ]; then
|
||||
EXAMPLES_CHANGED="true"
|
||||
else
|
||||
EXAMPLES_CHANGED=$([ ! -z "$CHANGED_FILES" ] && echo "true" || echo "false")
|
||||
fi
|
||||
|
||||
echo -e "CHANGED_FILES:\n$CHANGED_FILES"
|
||||
echo "EXAMPLES_CHANGED: $EXAMPLES_CHANGED"
|
||||
echo "examples_changed=$EXAMPLES_CHANGED" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: ./.github/actions/setup-python
|
||||
- uses: ./.github/actions/setup-pyenv
|
||||
|
||||
- name: Install dependencies
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || steps.check-diff.outputs.examples_changed == 'true'
|
||||
run: |
|
||||
source ./dev/install-common-deps.sh --ml
|
||||
uv pip install --system fastapi uvicorn
|
||||
# Required for the transformers example that uses the Whisper model
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ffmpeg
|
||||
|
||||
- name: Run example tests
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || steps.check-diff.outputs.examples_changed == 'true'
|
||||
env:
|
||||
SPARK_LOCAL_IP: localhost
|
||||
SPLITS: ${{ matrix.splits }}
|
||||
GROUP: ${{ matrix.group }}
|
||||
run: |
|
||||
pytest --splits=$SPLITS --group=$GROUP --serve-wheel tests/examples --durations=30
|
||||
|
||||
- name: Remove conda environments
|
||||
run: |
|
||||
./dev/remove-conda-envs.sh
|
||||
|
||||
- name: Show disk usage
|
||||
run: |
|
||||
df -h
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event_name == 'schedule' && github.repository == 'mlflow/dev') || (github.event_name == 'pull_request' && (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot'))
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: ${{ github.event_name == 'schedule' && 'mlflow/mlflow' || github.event.inputs.repository }}
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- name: Show disk usage
|
||||
run: |
|
||||
df -h
|
||||
@@ -0,0 +1,88 @@
|
||||
name: fs2db
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- branch-[0-9]+.[0-9]+
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- "dev/clint/**"
|
||||
- ".github/workflows/docs.yml"
|
||||
- ".github/workflows/preview-docs.yml"
|
||||
- "mlflow/server/js/**"
|
||||
- ".github/workflows/js.yml"
|
||||
- ".claude/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mlflow-version:
|
||||
- "2.22.4" # Latest 2.x release
|
||||
- "3.6.0" # First version after FileStore deprecation
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/setup-python
|
||||
|
||||
- name: Install MLflow ${{ matrix.mlflow-version }}
|
||||
env:
|
||||
MLFLOW_VERSION: ${{ matrix.mlflow-version }}
|
||||
run: uv run --with "mlflow==$MLFLOW_VERSION" --no-project mlflow --version
|
||||
|
||||
- name: Generate synthetic data for MLflow ${{ matrix.mlflow-version }}
|
||||
env:
|
||||
MLFLOW_VERSION: ${{ matrix.mlflow-version }}
|
||||
run: |
|
||||
uv run --with "mlflow==$MLFLOW_VERSION" --no-project python -I \
|
||||
fs2db/src/generate_synthetic_data.py --output /tmp/fs2db/$MLFLOW_VERSION/ --size full
|
||||
|
||||
- name: Run migration for MLflow ${{ matrix.mlflow-version }}
|
||||
env:
|
||||
MLFLOW_VERSION: ${{ matrix.mlflow-version }}
|
||||
run: |
|
||||
uv run mlflow migrate-filestore \
|
||||
--source /tmp/fs2db/$MLFLOW_VERSION/ \
|
||||
--target sqlite:////tmp/fs2db/$MLFLOW_VERSION/migrated.db \
|
||||
--no-progress
|
||||
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false || github.event.pull_request.user.login == 'Copilot' && github.event.pull_request.user.type == 'Bot')
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/untracked
|
||||
- uses: ./.github/actions/setup-python
|
||||
|
||||
- name: Run fs2db pytest tests
|
||||
run: uv run pytest tests/store/fs2db
|
||||
@@ -0,0 +1,112 @@
|
||||
# Daily benchmark for the MLflow AI Gateway to catch performance regressions.
|
||||
# Runs against both sqlite (1 instance) and postgres (4 instances + nginx) backends.
|
||||
#
|
||||
# THRESHOLD CALIBRATION: The default threshold values below are conservative
|
||||
# starting points. Run this workflow a few times and tighten thresholds to
|
||||
# ~2x the observed average. Override per-run via workflow_dispatch inputs.
|
||||
name: MLflow Gateway Benchmark
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at 06:00 UTC (off-peak; slow-tests runs at 13:00)
|
||||
- cron: "0 6 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
requests:
|
||||
description: "Requests per run"
|
||||
required: false
|
||||
default: "200"
|
||||
max_concurrent:
|
||||
description: "Max concurrent requests (blank = use per-backend default: 10 for both)"
|
||||
required: false
|
||||
default: ""
|
||||
max_p50_ms:
|
||||
description: "Max P50 latency ms (blank = use per-backend default)"
|
||||
required: false
|
||||
default: ""
|
||||
max_p99_ms:
|
||||
description: "Max P99 latency ms (blank = use per-backend default)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
benchmark:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
if: (github.event_name == 'schedule' && github.repository == 'mlflow/mlflow') || github.event_name == 'workflow_dispatch'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
backend: [sqlite, postgres]
|
||||
include:
|
||||
- backend: sqlite
|
||||
instances: 1
|
||||
default_max_concurrent: 10
|
||||
default_max_p50_ms: 300
|
||||
default_max_p99_ms: 800
|
||||
- backend: postgres
|
||||
instances: 4
|
||||
default_max_concurrent: 10
|
||||
default_max_p50_ms: 400
|
||||
default_max_p99_ms: 1000
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: ./.github/actions/setup-python
|
||||
- name: Install dependencies
|
||||
env:
|
||||
BACKEND: ${{ matrix.backend }}
|
||||
run: |
|
||||
uv sync --extra gateway
|
||||
if [[ "$BACKEND" == "postgres" ]]; then
|
||||
uv pip install "psycopg2-binary>=2.9,<3"
|
||||
fi
|
||||
- name: Run benchmark (${{ matrix.backend }})
|
||||
env:
|
||||
MLFLOW_ENABLE_INCREMENTAL_SPAN_EXPORT: "true"
|
||||
MLFLOW_USE_BATCH_SPAN_PROCESSOR: "true"
|
||||
MLFLOW_ENABLE_ASYNC_TRACE_LOGGING: "true"
|
||||
BACKEND: ${{ matrix.backend }}
|
||||
INSTANCES: ${{ matrix.instances }}
|
||||
DEFAULT_MAX_CONCURRENT: ${{ matrix.default_max_concurrent }}
|
||||
DEFAULT_MAX_P50_MS: ${{ matrix.default_max_p50_ms }}
|
||||
DEFAULT_MAX_P99_MS: ${{ matrix.default_max_p99_ms }}
|
||||
REQUESTS: ${{ github.event_name == 'workflow_dispatch' && inputs.requests || '200' }}
|
||||
MAX_CONCURRENT_OVERRIDE: ${{ github.event_name == 'workflow_dispatch' && inputs.max_concurrent || '' }}
|
||||
MAX_P50_MS_OVERRIDE: ${{ github.event_name == 'workflow_dispatch' && inputs.max_p50_ms || '' }}
|
||||
MAX_P99_MS_OVERRIDE: ${{ github.event_name == 'workflow_dispatch' && inputs.max_p99_ms || '' }}
|
||||
run: |
|
||||
MAX_CONCURRENT="${MAX_CONCURRENT_OVERRIDE:-$DEFAULT_MAX_CONCURRENT}"
|
||||
MAX_P50_MS="${MAX_P50_MS_OVERRIDE:-$DEFAULT_MAX_P50_MS}"
|
||||
MAX_P99_MS="${MAX_P99_MS_OVERRIDE:-$DEFAULT_MAX_P99_MS}"
|
||||
ARGS=(
|
||||
--instances "$INSTANCES"
|
||||
--database "$BACKEND"
|
||||
--requests "$REQUESTS"
|
||||
--max-concurrent "$MAX_CONCURRENT"
|
||||
--max-p50-ms "$MAX_P50_MS"
|
||||
--max-p99-ms "$MAX_P99_MS"
|
||||
--runs 3
|
||||
)
|
||||
uv run --no-sync dev/benchmarks/gateway/run.py "${ARGS[@]}" \
|
||||
--output "benchmark-results-$BACKEND.json"
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: benchmark-results-${{ matrix.backend }}-${{ github.run_id }}
|
||||
path: benchmark-results-${{ matrix.backend }}.json
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Heads-Up
|
||||
|
||||
# Flag external PRs that touch files coding agents auto-load as instructions
|
||||
# (AGENTS.md, CLAUDE.md, .claude/, .agents/). Running an agent against such a
|
||||
# checkout silently obeys these files, so a malicious one can steer it without
|
||||
# approval. The 'heads-up' label warns reviewers to read them first.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
# Watched paths. Add new entries here to widen coverage.
|
||||
paths:
|
||||
- "AGENTS.md"
|
||||
- "**/AGENTS.md"
|
||||
- "CLAUDE.md"
|
||||
- "**/CLAUDE.md"
|
||||
- ".agents/**"
|
||||
- ".claude/**"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
label-heads-up:
|
||||
# Only flag external contributors. Internal edits to these files are routine
|
||||
# and don't need a review signal.
|
||||
if: >
|
||||
github.repository == 'mlflow/mlflow'
|
||||
&& github.event.pull_request.user.type != 'Bot'
|
||||
&& !contains(fromJson('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label "heads-up"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user