logiguard fork v3: full patch set on verified 8c74db0 tree
Includes prior-session patches (carry forward so the app compiles): - crates/gpui/build.rs: cross-compile manifest fix - crates/gpui/src/platform.rs: PlatformWindow::activate_with_token trait method - crates/gpui/src/window.rs: Window::activate_with_token public API - crates/gpui_linux/src/linux/wayland/window.rs: WaylandWindow::activate_with_token + activate() keyboard-serial fix Plus the focus-serial fix: - serial.rs: SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; latest_serial_of() Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
69
script/analyze_highlights.py
Normal file
69
script/analyze_highlights.py
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
This script analyzes all the highlights.scm files in our embedded languages and extensions.
|
||||
It counts the number of unique instances of @{name} and the languages in which they are used.
|
||||
|
||||
This is useful to help avoid accidentally introducing new tags when appropriate ones already exist when adding new languages.
|
||||
|
||||
Flags:
|
||||
-v, --verbose: Include a detailed list of languages for each tag found in the highlights.scm files.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
pattern = re.compile(r'@(?!_)[a-zA-Z_.]+')
|
||||
|
||||
def parse_arguments():
|
||||
parser = argparse.ArgumentParser(description='Analyze highlights.scm files for unique instances and their languages.')
|
||||
parser.add_argument('-v', '--verbose', action='store_true', help='Include a list of languages for each tag.')
|
||||
return parser.parse_args()
|
||||
|
||||
def find_highlight_files(root_dir):
|
||||
for path in Path(root_dir).rglob('highlights.scm'):
|
||||
yield path
|
||||
|
||||
def count_instances(files):
|
||||
instances: defaultdict[list[Any], dict[str, Any]] = defaultdict(lambda: {'count': 0, 'languages': set()})
|
||||
for file_path in files:
|
||||
language = file_path.parent.name
|
||||
with open(file_path, "r") as file:
|
||||
text = file.read()
|
||||
matches = pattern.findall(text)
|
||||
for match in matches:
|
||||
instances[match]['count'] += 1
|
||||
instances[match]['languages'].add(language)
|
||||
return instances
|
||||
|
||||
def print_instances(instances, verbose=False):
|
||||
for item, details in sorted(instances.items(), key=lambda x: x[0]):
|
||||
languages = ', '.join(sorted(details['languages']))
|
||||
if verbose:
|
||||
print(f"{item} ({details['count']}) - [{languages}]")
|
||||
else:
|
||||
print(f"{item} ({details['count']})")
|
||||
|
||||
def main():
|
||||
args = parse_arguments()
|
||||
|
||||
base_dir = Path(__file__).parent.parent
|
||||
core_path = base_dir / 'crates/languages/src'
|
||||
extension_path = base_dir / 'extensions/'
|
||||
|
||||
core_instances = count_instances(find_highlight_files(core_path))
|
||||
extension_instances = count_instances(find_highlight_files(extension_path))
|
||||
|
||||
unique_extension_instances = {k: v for k, v in extension_instances.items() if k not in core_instances}
|
||||
|
||||
print('Shared:\n')
|
||||
print_instances(core_instances, args.verbose)
|
||||
|
||||
if unique_extension_instances:
|
||||
print('\nExtension-only:\n')
|
||||
print_instances(unique_extension_instances, args.verbose)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
53
script/bootstrap
Executable file
53
script/bootstrap
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
# if root or if sudo/unavailable, define an empty variable
|
||||
if [ "$(id -u)" -eq 0 ]
|
||||
then maysudo=''
|
||||
else maysudo="$(command -v sudo || command -v doas || true)"
|
||||
fi
|
||||
|
||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
echo "Linux dependencies..."
|
||||
script/linux
|
||||
else
|
||||
echo "installing foreman..."
|
||||
which foreman > /dev/null || brew install foreman
|
||||
fi
|
||||
|
||||
# Install minio if needed
|
||||
if ! which minio > /dev/null; then
|
||||
if command -v brew > /dev/null; then
|
||||
echo "minio not found. Installing via brew"
|
||||
brew install minio/stable/minio
|
||||
elif command -v apt > /dev/null; then
|
||||
echo "minio not found. Installing via apt from https://dl.min.io/server/minio/release/linux-amd64/minio.deb"
|
||||
wget -q https://dl.min.io/server/minio/release/linux-amd64/minio.deb -O /tmp/minio.deb
|
||||
$maysudo apt install /tmp/minio.deb
|
||||
rm -f /tmp/minio.deb
|
||||
elif command -v dnf > /dev/null; then
|
||||
echo "minio not found. Installing via dnf from https://dl.min.io/server/minio/release/linux-amd64/minio.rpm"
|
||||
wget -q https://dl.min.io/server/minio/release/linux-amd64/minio.rpm -O /tmp/minio.rpm
|
||||
$maysudo dnf install /tmp/minio.rpm
|
||||
rm -f /tmp/minio.rpm
|
||||
else
|
||||
echo "No supported package manager found (brew, apt, or dnf)"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Install sqlx-cli if needed
|
||||
if ! [[ "$(command -v sqlx)" && "$(sqlx --version)" == "sqlx-cli 0.7.2" ]]; then
|
||||
echo "sqlx-cli not found or not the required version, installing version 0.7.2..."
|
||||
cargo install sqlx-cli --version 0.7.2
|
||||
fi
|
||||
|
||||
cd crates/collab
|
||||
|
||||
# Export contents of .env.toml
|
||||
eval "$(cargo run --bin dotenv)"
|
||||
|
||||
echo "creating databases..."
|
||||
sqlx database create --database-url "$DATABASE_URL"
|
||||
sqlx database create --database-url "$LLM_DATABASE_URL"
|
||||
21
script/bootstrap.ps1
Normal file
21
script/bootstrap.ps1
Normal file
@@ -0,0 +1,21 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $true
|
||||
|
||||
$env:POWERSHELL = $true
|
||||
|
||||
if (!(Get-Command sqlx -ErrorAction SilentlyContinue) -or (sqlx --version) -notlike "sqlx-cli 0.7.2") {
|
||||
Write-Output "sqlx-cli not found or not the required version, installing version 0.7.2..."
|
||||
cargo install sqlx-cli --version 0.7.2
|
||||
}
|
||||
|
||||
Set-Location .\crates\collab
|
||||
|
||||
# Export contents of .env.toml
|
||||
$env = (cargo run --bin dotenv) -join "`n";
|
||||
Invoke-Expression $env
|
||||
|
||||
Set-Location ../..
|
||||
|
||||
Write-Output "creating databases..."
|
||||
sqlx database create --database-url "$env:DATABASE_URL"
|
||||
sqlx database create --database-url "$env:LLM_DATABASE_URL"
|
||||
25
script/build-docker
Executable file
25
script/build-docker
Executable file
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Use a docker BASE_IMAGE to test building Zed.
|
||||
# e.g: ./script/bundle-docker ubuntu:20.04
|
||||
#
|
||||
# Increasing resources available to podman may speed this up:
|
||||
# podman machine stop
|
||||
# podman machine set --memory 16384 --cpus 8 --disk-size 200
|
||||
# podman machine start
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_IMAGE=${BASE_IMAGE:-${1:-}}
|
||||
if [ -z "$BASE_IMAGE" ]; then
|
||||
echo "Usage: $0 BASE_IMAGE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export DOCKER_BUILDKIT=1
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
podman build . \
|
||||
-f Dockerfile-distros \
|
||||
-t many \
|
||||
--build-arg BASE_IMAGE="$BASE_IMAGE"
|
||||
7
script/bump-extension-cli
Executable file
7
script/bump-extension-cli
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
git pull --ff-only origin main
|
||||
git tag -f extension-cli
|
||||
git push -f origin extension-cli
|
||||
45
script/bump-gpui-version
Executable file
45
script/bump-gpui-version
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Parse arguments
|
||||
bump_type=${1:-minor}
|
||||
|
||||
if [[ "$bump_type" != "minor" && "$bump_type" != "patch" ]]; then
|
||||
echo "Usage: $0 [minor|patch]"
|
||||
echo " minor (default): bumps the minor version (e.g., 0.1.0 -> 0.2.0)"
|
||||
echo " patch: bumps the patch version (e.g., 0.1.0 -> 0.1.1)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure we're in a clean state on an up-to-date `main` branch.
|
||||
if [[ -n $(git status --short --untracked-files=no) ]]; then
|
||||
echo "can't bump versions with uncommitted changes"
|
||||
exit 1
|
||||
fi
|
||||
if [[ $(git rev-parse --abbrev-ref HEAD) != "main" ]]; then
|
||||
echo "this command must be run on main"
|
||||
exit 1
|
||||
fi
|
||||
git pull -q --ff-only origin main
|
||||
|
||||
|
||||
# Parse the current version
|
||||
version=$(script/get-crate-version gpui)
|
||||
major=$(echo $version | cut -d. -f1)
|
||||
minor=$(echo $version | cut -d. -f2)
|
||||
patch=$(echo $version | cut -d. -f3)
|
||||
|
||||
if [[ "$bump_type" == "minor" ]]; then
|
||||
next_minor=$(expr $minor + 1)
|
||||
next_version="${major}.${next_minor}.0"
|
||||
else
|
||||
next_patch=$(expr $patch + 1)
|
||||
next_version="${major}.${minor}.${next_patch}"
|
||||
fi
|
||||
|
||||
branch_name="bump-gpui-to-v${next_version}"
|
||||
|
||||
git checkout -b ${branch_name}
|
||||
|
||||
script/lib/bump-version.sh gpui gpui-v "" $bump_type true
|
||||
|
||||
git checkout -q main
|
||||
7
script/bump-nightly
Executable file
7
script/bump-nightly
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
git fetch origin main:tags/nightly -f
|
||||
git log --oneline -1 nightly
|
||||
git push -f origin nightly
|
||||
50
script/bump-zed-version
Executable file
50
script/bump-zed-version
Executable file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eu
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [target]"
|
||||
echo ""
|
||||
echo "Triggers the bump_zed_version workflow to perform a minor release version bump "
|
||||
echo "and update the stable and preview versions."
|
||||
echo ""
|
||||
echo "Arguments:"
|
||||
echo " target Which channels to bump: all (default), main, preview, or stable"
|
||||
exit 1
|
||||
}
|
||||
|
||||
target="${1:-all}"
|
||||
|
||||
if [[ "$target" != "all" && "$target" != "main" && "$target" != "preview" && "$target" != "stable" ]]; then
|
||||
echo "error: invalid target '$target'" >&2
|
||||
echo "Valid targets: all, main, preview, stable" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
day_of_week=$(date +%u)
|
||||
if [[ $day_of_week -ne 3 ]]; then
|
||||
day_name=$(date +%A)
|
||||
echo "Warning: Today is $day_name. Release version bumps are typically only done on Zednesdays."
|
||||
read -r -p "Continue anyway? (y/N) " confirm
|
||||
if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then
|
||||
echo "Aborted."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
which gh > /dev/null 2>&1 || {
|
||||
echo "error: GitHub CLI (gh) is required but not installed." >&2
|
||||
echo "Install it with: brew install gh" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "Triggering bump_zed_version workflow:"
|
||||
echo " target: $target"
|
||||
echo ""
|
||||
|
||||
gh workflow run bump_zed_version.yml \
|
||||
-f target="$target"
|
||||
|
||||
echo ""
|
||||
echo "Workflow triggered. Monitor progress at:"
|
||||
echo " https://github.com/zed-industries/zed/actions/workflows/bump_zed_version.yml"
|
||||
161
script/bundle-freebsd
Executable file
161
script/bundle-freebsd
Executable file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euxo pipefail
|
||||
source script/lib/blob-store.sh
|
||||
|
||||
# Function for displaying help info
|
||||
help_info() {
|
||||
echo "
|
||||
Usage: ${0##*/} [options]
|
||||
Build a release .tar.gz for FreeBSD.
|
||||
|
||||
Options:
|
||||
-h Display this help and exit.
|
||||
"
|
||||
}
|
||||
|
||||
while getopts 'h' flag; do
|
||||
case "${flag}" in
|
||||
h)
|
||||
help_info
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
export ZED_BUNDLE=true
|
||||
|
||||
channel=$(<crates/zed/RELEASE_CHANNEL)
|
||||
target_dir="${CARGO_TARGET_DIR:-target}"
|
||||
|
||||
version="$(script/get-crate-version zed)"
|
||||
# Set RELEASE_VERSION so it's compiled into GPUI and it knows about the version.
|
||||
export RELEASE_VERSION="${version}"
|
||||
|
||||
commit=$(git rev-parse HEAD | cut -c 1-7)
|
||||
|
||||
version_info=$(rustc --version --verbose)
|
||||
host_line=$(echo "$version_info" | grep host)
|
||||
target_triple=${host_line#*: }
|
||||
remote_server_triple=${REMOTE_SERVER_TARGET:-"${target_triple}"}
|
||||
|
||||
# musl_triple=${target_triple%-gnu}-musl
|
||||
# rustup_installed=false
|
||||
# if command -v rustup >/dev/null 2>&1; then
|
||||
# rustup_installed=true
|
||||
# fi
|
||||
# Generate the licenses first, so they can be baked into the binaries
|
||||
# script/generate-licenses
|
||||
# if "$rustup_installed"; then
|
||||
# rustup target add "$remote_server_triple"
|
||||
# fi
|
||||
|
||||
# export CC=$(which clang)
|
||||
|
||||
# Build binary in release mode
|
||||
export RUSTFLAGS="${RUSTFLAGS:-} -C link-args=-Wl,--disable-new-dtags,-rpath,\$ORIGIN/../lib"
|
||||
# cargo build --release --target "${target_triple}" --package zed --package cli
|
||||
|
||||
# Build remote_server in separate invocation to prevent feature unification from other crates
|
||||
# from influencing dynamic libraries required by it.
|
||||
# if [[ "$remote_server_triple" == "$musl_triple" ]]; then
|
||||
# export RUSTFLAGS="${RUSTFLAGS:-} -C target-feature=+crt-static"
|
||||
# fi
|
||||
cargo build --release --target "${remote_server_triple}" --package remote_server
|
||||
|
||||
# Strip debug symbols and save them for upload to DigitalOcean
|
||||
# objcopy --only-keep-debug "${target_dir}/${target_triple}/release/zed" "${target_dir}/${target_triple}/release/zed.dbg"
|
||||
# objcopy --only-keep-debug "${target_dir}/${remote_server_triple}/release/remote_server" "${target_dir}/${remote_server_triple}/release/remote_server.dbg"
|
||||
# objcopy --strip-debug "${target_dir}/${target_triple}/release/zed"
|
||||
# objcopy --strip-debug "${target_dir}/${target_triple}/release/cli"
|
||||
# objcopy --strip-debug "${target_dir}/${remote_server_triple}/release/remote_server"
|
||||
|
||||
# gzip -f "${target_dir}/${target_triple}/release/zed.dbg"
|
||||
# gzip -f "${target_dir}/${remote_server_triple}/release/remote_server.dbg"
|
||||
|
||||
# if [[ -n "${DIGITALOCEAN_SPACES_SECRET_KEY:-}" && -n "${DIGITALOCEAN_SPACES_ACCESS_KEY:-}" ]]; then
|
||||
# upload_to_blob_store_public \
|
||||
# "zed-debug-symbols" \
|
||||
# "${target_dir}/${target_triple}/release/zed.dbg.gz" \
|
||||
# "$channel/zed-$version-${target_triple}.dbg.gz"
|
||||
# upload_to_blob_store_public \
|
||||
# "zed-debug-symbols" \
|
||||
# "${target_dir}/${remote_server_triple}/release/remote_server.dbg.gz" \
|
||||
# "$channel/remote_server-$version-${remote_server_triple}.dbg.gz"
|
||||
# fi
|
||||
|
||||
# Ensure that remote_server does not depend on libssl nor libcrypto, as we got rid of these deps.
|
||||
if ldd "${target_dir}/${remote_server_triple}/release/remote_server" | grep -q 'libcrypto\|libssl'; then
|
||||
echo "Error: remote_server still depends on libssl or libcrypto" && exit 1
|
||||
fi
|
||||
|
||||
suffix=""
|
||||
if [ "$channel" != "stable" ]; then
|
||||
suffix="-$channel"
|
||||
fi
|
||||
|
||||
# Move everything that should end up in the final package
|
||||
# into a temp directory.
|
||||
# temp_dir=$(mktemp -d)
|
||||
# zed_dir="${temp_dir}/zed$suffix.app"
|
||||
|
||||
# Binary
|
||||
# mkdir -p "${zed_dir}/bin" "${zed_dir}/libexec"
|
||||
# cp "${target_dir}/${target_triple}/release/zed" "${zed_dir}/libexec/zed-editor"
|
||||
# cp "${target_dir}/${target_triple}/release/cli" "${zed_dir}/bin/zed"
|
||||
|
||||
# Libs
|
||||
# find_libs() {
|
||||
# ldd ${target_dir}/${target_triple}/release/zed |
|
||||
# cut -d' ' -f3 |
|
||||
# grep -v '\<\(libstdc++.so\|libc.so\|libgcc_s.so\|libm.so\|libpthread.so\|libdl.so\|libasound.so\)'
|
||||
# }
|
||||
|
||||
# mkdir -p "${zed_dir}/lib"
|
||||
# rm -rf "${zed_dir}/lib/*"
|
||||
# cp $(find_libs) "${zed_dir}/lib"
|
||||
|
||||
# Icons
|
||||
# mkdir -p "${zed_dir}/share/icons/hicolor/512x512/apps"
|
||||
# cp "crates/zed/resources/app-icon$suffix.png" "${zed_dir}/share/icons/hicolor/512x512/apps/zed.png"
|
||||
# mkdir -p "${zed_dir}/share/icons/hicolor/1024x1024/apps"
|
||||
# cp "crates/zed/resources/app-icon$suffix@2x.png" "${zed_dir}/share/icons/hicolor/1024x1024/apps/zed.png"
|
||||
|
||||
# .desktop
|
||||
# export DO_STARTUP_NOTIFY="true"
|
||||
# export APP_CLI="zed"
|
||||
# export APP_ICON="zed"
|
||||
# export APP_ARGS="%U"
|
||||
# if [[ "$channel" == "preview" ]]; then
|
||||
# export APP_NAME="Zed Preview"
|
||||
# elif [[ "$channel" == "nightly" ]]; then
|
||||
# export APP_NAME="Zed Nightly"
|
||||
# elif [[ "$channel" == "dev" ]]; then
|
||||
# export APP_NAME="Zed Devel"
|
||||
# else
|
||||
# export APP_NAME="Zed"
|
||||
# fi
|
||||
|
||||
# mkdir -p "${zed_dir}/share/applications"
|
||||
# envsubst <"crates/zed/resources/zed.desktop.in" >"${zed_dir}/share/applications/zed$suffix.desktop"
|
||||
# chmod +x "${zed_dir}/share/applications/zed$suffix.desktop"
|
||||
|
||||
# Copy generated licenses so they'll end up in archive too
|
||||
# cp "assets/licenses.md" "${zed_dir}/licenses.md"
|
||||
|
||||
# Create archive out of everything that's in the temp directory
|
||||
arch=$(uname -m)
|
||||
# target="freebsd-${arch}"
|
||||
# if [[ "$channel" == "dev" ]]; then
|
||||
# archive="zed-${commit}-${target}.tar.gz"
|
||||
# else
|
||||
# archive="zed-${target}.tar.gz"
|
||||
# fi
|
||||
|
||||
# rm -rf "${archive}"
|
||||
# remove_match="zed(-[a-zA-Z0-9]+)?-linux-$(uname -m)\.tar\.gz"
|
||||
# ls "${target_dir}/release" | grep -E ${remove_match} | xargs -d "\n" -I {} rm -f "${target_dir}/release/{}" || true
|
||||
# tar -czvf "${target_dir}/release/$archive" -C ${temp_dir} "zed$suffix.app"
|
||||
|
||||
gzip -f --stdout --best "${target_dir}/${remote_server_triple}/release/remote_server" \
|
||||
> "${target_dir}/zed-remote-server-freebsd-x86_64.gz"
|
||||
205
script/bundle-linux
Executable file
205
script/bundle-linux
Executable file
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euxo pipefail
|
||||
source script/lib/blob-store.sh
|
||||
|
||||
# Function for displaying help info
|
||||
help_info() {
|
||||
echo "
|
||||
Usage: ${0##*/} [options]
|
||||
Build a release .tar.gz for Linux.
|
||||
|
||||
Options:
|
||||
-h, --help Display this help and exit.
|
||||
--flatpak Set ZED_BUNDLE_TYPE=flatpak so that this can be included in system info
|
||||
"
|
||||
}
|
||||
|
||||
# Parse all arguments manually
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-h|--help)
|
||||
help_info
|
||||
exit 0
|
||||
;;
|
||||
--flatpak)
|
||||
export ZED_BUNDLE_TYPE=flatpak
|
||||
shift
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
-*)
|
||||
echo "Unknown option: $1" >&2
|
||||
help_info
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Error: Unexpected argument: $1" >&2
|
||||
help_info
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
export ZED_BUNDLE=true
|
||||
|
||||
channel=$(<crates/zed/RELEASE_CHANNEL)
|
||||
target_dir="${CARGO_TARGET_DIR:-target}"
|
||||
|
||||
version="$(script/get-crate-version zed)"
|
||||
# Set RELEASE_VERSION so it's compiled into GPUI and it knows about the version.
|
||||
export RELEASE_VERSION="${version}"
|
||||
|
||||
commit=$(git rev-parse HEAD | cut -c 1-7)
|
||||
|
||||
version_info=$(rustc --version --verbose)
|
||||
host_line=$(echo "$version_info" | grep host)
|
||||
target_triple=${host_line#*: }
|
||||
musl_triple=${target_triple%-gnu}-musl
|
||||
remote_server_triple=${REMOTE_SERVER_TARGET:-"${musl_triple}"}
|
||||
rustup_installed=false
|
||||
if command -v rustup >/dev/null 2>&1; then
|
||||
rustup_installed=true
|
||||
fi
|
||||
|
||||
# Generate the licenses first, so they can be baked into the binaries
|
||||
script/generate-licenses
|
||||
|
||||
if "$rustup_installed"; then
|
||||
rustup target add "$remote_server_triple"
|
||||
fi
|
||||
|
||||
export CC=${CC:-$(which clang)}
|
||||
|
||||
# Build binary in release mode
|
||||
# We need lld to link libwebrtc.a successfully on aarch64-linux.
|
||||
# NOTE: Since RUSTFLAGS env var overrides all .cargo/config.toml rustflags
|
||||
# (see https://github.com/rust-lang/cargo/issues/5376), the
|
||||
# [target.aarch64-unknown-linux-gnu] section in config.toml has no effect here.
|
||||
if [[ "$(uname -m)" == "aarch64" ]]; then
|
||||
export RUSTFLAGS="${RUSTFLAGS:-} -C link-arg=-fuse-ld=lld -C link-args=-Wl,--disable-new-dtags,-rpath,\$ORIGIN/../lib"
|
||||
else
|
||||
export RUSTFLAGS="${RUSTFLAGS:-} -C link-args=-Wl,--disable-new-dtags,-rpath,\$ORIGIN/../lib"
|
||||
fi
|
||||
cargo build --release --target "${target_triple}" --package zed --package cli
|
||||
# Build remote_server in separate invocation to prevent feature unification from other crates
|
||||
# from influencing dynamic libraries required by it.
|
||||
if [[ "$remote_server_triple" == "$musl_triple" ]]; then
|
||||
export RUSTFLAGS="${RUSTFLAGS:-} -C target-feature=+crt-static"
|
||||
fi
|
||||
cargo build --release --target "${remote_server_triple}" --package remote_server
|
||||
|
||||
# Upload debug info to sentry.io
|
||||
if ! command -v sentry-cli >/dev/null 2>&1; then
|
||||
echo "sentry-cli not found. skipping sentry upload."
|
||||
echo "install with: 'curl -sL https://sentry.io/get-cli | bash'"
|
||||
else
|
||||
if [[ -n "${SENTRY_AUTH_TOKEN:-}" ]]; then
|
||||
echo "Uploading zed debug symbols to sentry..."
|
||||
# note: this uploads the unstripped binary which is needed because it contains
|
||||
# .eh_frame data for stack unwinding. see https://github.com/getsentry/symbolic/issues/783
|
||||
for attempt in 1 2 3; do
|
||||
echo "Attempting sentry upload (attempt $attempt/3)..."
|
||||
if sentry-cli debug-files upload --include-sources --wait -p zed -o zed-dev \
|
||||
"${target_dir}/${target_triple}"/release/zed \
|
||||
"${target_dir}/${remote_server_triple}"/release/remote_server; then
|
||||
echo "Sentry upload successful on attempt $attempt"
|
||||
break
|
||||
else
|
||||
echo "Sentry upload failed on attempt $attempt"
|
||||
if [ $attempt -eq 3 ]; then
|
||||
echo "All sentry upload attempts failed"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "missing SENTRY_AUTH_TOKEN. skipping sentry upload."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Strip debug symbols and save them for upload to DigitalOcean.
|
||||
# We use llvm-objcopy because GNU objcopy on older distros (e.g. Ubuntu 20.04)
|
||||
# doesn't understand CREL sections produced by newer LLVM.
|
||||
llvm-objcopy --strip-debug "${target_dir}/${target_triple}/release/zed"
|
||||
llvm-objcopy --strip-debug "${target_dir}/${target_triple}/release/cli"
|
||||
llvm-objcopy --strip-debug "${target_dir}/${remote_server_triple}/release/remote_server"
|
||||
|
||||
# Ensure that remote_server does not depend on libssl nor libcrypto, as we got rid of these deps.
|
||||
if ldd "${target_dir}/${remote_server_triple}/release/remote_server" | grep -q 'libcrypto\|libssl'; then
|
||||
if [[ "$remote_server_triple" == *-musl ]]; then
|
||||
echo "Error: remote_server still depends on libssl or libcrypto" && exit 1
|
||||
else
|
||||
echo "Info: Using non-musl remote-server build."
|
||||
fi
|
||||
fi
|
||||
|
||||
suffix=""
|
||||
if [ "$channel" != "stable" ]; then
|
||||
suffix="-$channel"
|
||||
fi
|
||||
|
||||
# Move everything that should end up in the final package
|
||||
# into a temp directory.
|
||||
temp_dir=$(mktemp -d)
|
||||
zed_dir="${temp_dir}/zed$suffix.app"
|
||||
|
||||
# Binary
|
||||
mkdir -p "${zed_dir}/bin" "${zed_dir}/libexec"
|
||||
cp "${target_dir}/${target_triple}/release/zed" "${zed_dir}/libexec/zed-editor"
|
||||
cp "${target_dir}/${target_triple}/release/cli" "${zed_dir}/bin/zed"
|
||||
|
||||
# Libs
|
||||
find_libs() {
|
||||
ldd ${target_dir}/${target_triple}/release/zed |\
|
||||
cut -d' ' -f3 |\
|
||||
grep -v '\<\(libstdc++.so\|libc.so\|libgcc_s.so\|libm.so\|libpthread.so\|libdl.so\|libasound.so\)'
|
||||
}
|
||||
|
||||
mkdir -p "${zed_dir}/lib"
|
||||
rm -rf "${zed_dir}/lib/*"
|
||||
cp $(find_libs) "${zed_dir}/lib"
|
||||
|
||||
# Icons
|
||||
mkdir -p "${zed_dir}/share/icons/hicolor/512x512/apps"
|
||||
cp "crates/zed/resources/app-icon$suffix.png" "${zed_dir}/share/icons/hicolor/512x512/apps/zed.png"
|
||||
mkdir -p "${zed_dir}/share/icons/hicolor/1024x1024/apps"
|
||||
cp "crates/zed/resources/app-icon$suffix@2x.png" "${zed_dir}/share/icons/hicolor/1024x1024/apps/zed.png"
|
||||
|
||||
# .desktop
|
||||
export DO_STARTUP_NOTIFY="true"
|
||||
export APP_CLI="zed"
|
||||
export APP_ICON="zed"
|
||||
export APP_ARGS="%U"
|
||||
if [[ "$channel" == "preview" ]]; then
|
||||
export APP_NAME="Zed Preview"
|
||||
APP_ID="dev.zed.Zed-Preview"
|
||||
elif [[ "$channel" == "nightly" ]]; then
|
||||
export APP_NAME="Zed Nightly"
|
||||
APP_ID="dev.zed.Zed-Nightly"
|
||||
elif [[ "$channel" == "dev" ]]; then
|
||||
export APP_NAME="Zed Devel"
|
||||
APP_ID="dev.zed.Zed-Dev"
|
||||
else
|
||||
export APP_NAME="Zed"
|
||||
APP_ID="dev.zed.Zed"
|
||||
fi
|
||||
|
||||
mkdir -p "${zed_dir}/share/applications"
|
||||
envsubst < "crates/zed/resources/zed.desktop.in" > "${zed_dir}/share/applications/$APP_ID.desktop"
|
||||
chmod +x "${zed_dir}/share/applications/$APP_ID.desktop"
|
||||
|
||||
# Copy generated licenses so they'll end up in archive too
|
||||
cp "assets/licenses.md" "${zed_dir}/licenses.md"
|
||||
|
||||
# Create archive out of everything that's in the temp directory
|
||||
arch=$(uname -m)
|
||||
archive="zed-linux-${arch}.tar.gz"
|
||||
|
||||
rm -rf "${archive}"
|
||||
remove_match="zed(-[a-zA-Z0-9]+)?-linux-$(uname -m)\.tar\.gz"
|
||||
ls "${target_dir}/release" | grep -E ${remove_match} | xargs -d "\n" -I {} rm -f "${target_dir}/release/{}" || true
|
||||
tar -czvf "${target_dir}/release/$archive" -C ${temp_dir} "zed$suffix.app"
|
||||
|
||||
gzip -f --stdout --best "${target_dir}/${remote_server_triple}/release/remote_server" > "${target_dir}/zed-remote-server-linux-${arch}.gz"
|
||||
340
script/bundle-mac
Executable file
340
script/bundle-mac
Executable file
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
source script/lib/blob-store.sh
|
||||
|
||||
build_flag="--release"
|
||||
target_dir="release"
|
||||
open_result=false
|
||||
local_install=false
|
||||
can_code_sign=false
|
||||
|
||||
# This must match the team in the provisioning profile.
|
||||
IDENTITY="Zed Industries, Inc."
|
||||
APPLE_NOTARIZATION_TEAM="MQ55VZLNZQ"
|
||||
|
||||
# Function for displaying help info
|
||||
help_info() {
|
||||
echo "
|
||||
Usage: ${0##*/} [options] [architecture=host]
|
||||
Build the application bundle for macOS.
|
||||
|
||||
Options:
|
||||
-d Compile in debug mode
|
||||
-o Open dir with the resulting DMG or launch the app itself in local mode.
|
||||
-i Install the resulting DMG into /Applications.
|
||||
-h Display this help and exit.
|
||||
"
|
||||
}
|
||||
|
||||
while getopts 'dloih' flag
|
||||
do
|
||||
case "${flag}" in
|
||||
o) open_result=true;;
|
||||
d)
|
||||
export CARGO_INCREMENTAL=true
|
||||
export CARGO_BUNDLE_SKIP_BUILD=true
|
||||
build_flag="";
|
||||
target_dir="debug"
|
||||
;;
|
||||
i) local_install=true;;
|
||||
h)
|
||||
help_info
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
shift $((OPTIND-1))
|
||||
|
||||
|
||||
# Get release channel
|
||||
pushd crates/zed
|
||||
channel=$(<RELEASE_CHANNEL)
|
||||
export ZED_RELEASE_CHANNEL="${channel}"
|
||||
popd
|
||||
|
||||
export ZED_BUNDLE=true
|
||||
|
||||
cargo_bundle_version=$(cargo -q bundle --help 2>&1 | head -n 1 || echo "")
|
||||
if [ "$cargo_bundle_version" != "cargo-bundle v0.6.1-zed" ]; then
|
||||
cargo install cargo-bundle --git https://github.com/zed-industries/cargo-bundle.git --branch zed-deploy
|
||||
fi
|
||||
|
||||
# Deal with versions of macOS that don't include libstdc++ headers
|
||||
export CXXFLAGS="-stdlib=libc++"
|
||||
|
||||
version_info=$(rustc --version --verbose)
|
||||
host_line=$(echo "$version_info" | grep host)
|
||||
target_triple=${host_line#*: }
|
||||
if [[ $# -gt 0 && -n "$1" ]]; then
|
||||
target_triple="$1"
|
||||
fi
|
||||
arch_suffix=""
|
||||
|
||||
if [[ "$target_triple" = "x86_64-apple-darwin" ]]; then
|
||||
arch_suffix="x86_64"
|
||||
elif [[ "$target_triple" = "aarch64-apple-darwin" ]]; then
|
||||
arch_suffix="aarch64"
|
||||
else
|
||||
echo "Unsupported architecture $target_triple"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Generate the licenses first, so they can be baked into the binaries
|
||||
script/generate-licenses
|
||||
|
||||
rustup target add $target_triple
|
||||
|
||||
echo "Compiling zed binaries"
|
||||
cargo build ${build_flag} --package zed --package cli --target $target_triple
|
||||
# Build remote_server in separate invocation to prevent feature unification from other crates
|
||||
# from influencing dynamic libraries required by it.
|
||||
cargo build ${build_flag} --package remote_server --target $target_triple
|
||||
|
||||
echo "Creating application bundle"
|
||||
pushd crates/zed
|
||||
cp Cargo.toml Cargo.toml.backup
|
||||
sed \
|
||||
-i.backup \
|
||||
"s/package.metadata.bundle-${channel}/package.metadata.bundle/" \
|
||||
Cargo.toml
|
||||
|
||||
app_path=$(cargo bundle ${build_flag} --target $target_triple --select-workspace-root | xargs)
|
||||
|
||||
mv Cargo.toml.backup Cargo.toml
|
||||
popd
|
||||
echo "Bundled ${app_path}"
|
||||
|
||||
# DocumentTypes.plist references CFBundleTypeIconFile "Document", so the bundle must contain Document.icns.
|
||||
# We use the app icon as a placeholder document icon for now.
|
||||
document_icon_source="crates/zed/resources/Document.icns"
|
||||
document_icon_target="${app_path}/Contents/Resources/Document.icns"
|
||||
if [[ -f "${document_icon_source}" ]]; then
|
||||
mkdir -p "$(dirname "${document_icon_target}")"
|
||||
cp "${document_icon_source}" "${document_icon_target}"
|
||||
else
|
||||
echo "cargo::warning=Missing ${document_icon_source}; macOS document icons may not appear in Finder."
|
||||
fi
|
||||
|
||||
if [[ -n "${MACOS_CERTIFICATE:-}" && -n "${MACOS_CERTIFICATE_PASSWORD:-}" && -n "${APPLE_NOTARIZATION_KEY:-}" && -n "${APPLE_NOTARIZATION_KEY_ID:-}" && -n "${APPLE_NOTARIZATION_ISSUER_ID:-}" ]]; then
|
||||
can_code_sign=true
|
||||
|
||||
echo "Setting up keychain for code signing..."
|
||||
security create-keychain -p "$MACOS_CERTIFICATE_PASSWORD" zed.keychain || echo ""
|
||||
security default-keychain -s zed.keychain
|
||||
security unlock-keychain -p "$MACOS_CERTIFICATE_PASSWORD" zed.keychain
|
||||
# Calling set-keychain-settings without `-t` disables the auto-lock timeout
|
||||
security set-keychain-settings zed.keychain
|
||||
echo "$MACOS_CERTIFICATE" | base64 --decode > /tmp/zed-certificate.p12
|
||||
security import /tmp/zed-certificate.p12 -k zed.keychain -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||
rm /tmp/zed-certificate.p12
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CERTIFICATE_PASSWORD" zed.keychain
|
||||
|
||||
function cleanup() {
|
||||
echo "Cleaning up keychain"
|
||||
security default-keychain -s login.keychain
|
||||
security delete-keychain zed.keychain
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
fi
|
||||
|
||||
GIT_VERSION="v2.43.3"
|
||||
GIT_VERSION_SHA="fa29823"
|
||||
|
||||
function download_and_unpack() {
|
||||
local url=$1
|
||||
local path_to_unpack=$2
|
||||
local target_path=$3
|
||||
|
||||
temp_dir=$(mktemp -d)
|
||||
|
||||
if ! command -v curl &> /dev/null; then
|
||||
echo "curl is not installed. Please install curl to continue."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl --silent --fail --location "$url" | tar -xvz -C "$temp_dir" -f - $path_to_unpack
|
||||
|
||||
mv "$temp_dir/$path_to_unpack" "$target_path"
|
||||
|
||||
rm -rf "$temp_dir"
|
||||
}
|
||||
|
||||
function download_git() {
|
||||
local architecture=$1
|
||||
local target_binary=$2
|
||||
|
||||
tmp_dir=$(mktemp -d)
|
||||
pushd "$tmp_dir"
|
||||
|
||||
case "$architecture" in
|
||||
aarch64-apple-darwin)
|
||||
download_and_unpack "https://github.com/desktop/dugite-native/releases/download/${GIT_VERSION}/dugite-native-${GIT_VERSION}-${GIT_VERSION_SHA}-macOS-arm64.tar.gz" bin/git ./git
|
||||
;;
|
||||
x86_64-apple-darwin)
|
||||
download_and_unpack "https://github.com/desktop/dugite-native/releases/download/${GIT_VERSION}/dugite-native-${GIT_VERSION}-${GIT_VERSION_SHA}-macOS-x64.tar.gz" bin/git ./git
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $architecture"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
popd
|
||||
|
||||
mv "${tmp_dir}/git" "${target_binary}"
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
|
||||
function sign_app_binaries() {
|
||||
rm -rf "${app_path}/Contents/Frameworks"
|
||||
mkdir -p "${app_path}/Contents/Frameworks"
|
||||
|
||||
echo "Downloading git binary"
|
||||
download_git "${target_triple}" "${app_path}/Contents/MacOS/git"
|
||||
|
||||
# Note: The app identifier for our development builds is the same as the app identifier for nightly.
|
||||
cp crates/zed/contents/$channel/embedded.provisionprofile "${app_path}/Contents/"
|
||||
|
||||
if [[ $can_code_sign = true ]]; then
|
||||
echo "Code signing binaries"
|
||||
# sequence of codesign commands modeled after this example: https://developer.apple.com/forums/thread/701514
|
||||
/usr/bin/codesign --deep --force --timestamp --options runtime --sign "$IDENTITY" "${app_path}/Contents/MacOS/cli" -v
|
||||
/usr/bin/codesign --deep --force --timestamp --options runtime --sign "$IDENTITY" "${app_path}/Contents/MacOS/git" -v
|
||||
/usr/bin/codesign --deep --force --timestamp --options runtime --entitlements crates/zed/resources/zed.entitlements --sign "$IDENTITY" "${app_path}/Contents/MacOS/zed" -v
|
||||
/usr/bin/codesign --force --timestamp --options runtime --entitlements crates/zed/resources/zed.entitlements --sign "$IDENTITY" "${app_path}" -v
|
||||
else
|
||||
echo "One or more of the following variables are missing: MACOS_CERTIFICATE, MACOS_CERTIFICATE_PASSWORD, APPLE_NOTARIZATION_KEY, APPLE_NOTARIZATION_KEY_ID, APPLE_NOTARIZATION_ISSUER_ID"
|
||||
|
||||
echo "====== WARNING ======"
|
||||
echo "This bundle is being signed without all entitlements, some features (e.g. universal links) will not work"
|
||||
echo "====== WARNING ======"
|
||||
|
||||
# NOTE: if you need to test universal links you have a few paths forward:
|
||||
# - create a PR and tag it with the `run-bundling` label, and download the .dmg file from there.
|
||||
# - get a signing key for the MQ55VZLNZQ team from Nathan.
|
||||
# - create your own signing key, and update references to MQ55VZLNZQ to your own team ID
|
||||
# then comment out this line.
|
||||
cat crates/zed/resources/zed.entitlements | sed '/com.apple.developer.associated-domains/,+1d' > "${app_path}/Contents/Resources/zed.entitlements"
|
||||
|
||||
codesign --force --deep --entitlements "${app_path}/Contents/Resources/zed.entitlements" --sign ${MACOS_SIGNING_KEY:- -} "${app_path}" -v
|
||||
fi
|
||||
|
||||
bundle_name=$(basename "$app_path")
|
||||
|
||||
if [ "$local_install" = true ]; then
|
||||
rm -rf "/Applications/$bundle_name"
|
||||
mv "$app_path" "/Applications/$bundle_name"
|
||||
echo "Installed application bundle: /Applications/$bundle_name"
|
||||
if [ "$open_result" = true ]; then
|
||||
echo "Opening /Applications/$bundle_name"
|
||||
open "/Applications/$bundle_name"
|
||||
fi
|
||||
elif [ "$open_result" = true ]; then
|
||||
open "$app_path"
|
||||
fi
|
||||
|
||||
if [[ "$target_dir" = "debug" ]]; then
|
||||
echo "Debug build detected - skipping DMG creation and signing"
|
||||
if [ "$local_install" = false ]; then
|
||||
echo "Created application bundle:"
|
||||
echo "$app_path"
|
||||
fi
|
||||
else
|
||||
dmg_target_directory="target/${target_triple}/${target_dir}"
|
||||
dmg_source_directory="${dmg_target_directory}/dmg"
|
||||
dmg_file_path="${dmg_target_directory}/Zed-${arch_suffix}.dmg"
|
||||
xcode_bin_dir_path="$(xcode-select -p)/usr/bin"
|
||||
|
||||
rm -rf ${dmg_source_directory}
|
||||
mkdir -p ${dmg_source_directory}
|
||||
mv "${app_path}" "${dmg_source_directory}"
|
||||
notarization_key_file=$(mktemp)
|
||||
|
||||
echo "Adding symlink to /Applications to ${dmg_source_directory}"
|
||||
ln -s /Applications ${dmg_source_directory}
|
||||
|
||||
echo "Creating final DMG at ${dmg_file_path} using ${dmg_source_directory}"
|
||||
hdiutil create -volname Zed -srcfolder "${dmg_source_directory}" -ov -format UDZO "${dmg_file_path}"
|
||||
|
||||
# If someone runs this bundle script locally, a symlink will be placed in `dmg_source_directory`.
|
||||
# This symlink causes CPU issues with Zed if the Zed codebase is the project being worked on, so we simply remove it for now.
|
||||
echo "Removing symlink to /Applications from ${dmg_source_directory}"
|
||||
rm ${dmg_source_directory}/Applications
|
||||
|
||||
echo "Adding license agreement to DMG"
|
||||
npm install --global dmg-license minimist
|
||||
dmg-license script/terms/terms.json "${dmg_file_path}"
|
||||
|
||||
if [[ $can_code_sign = true ]]; then
|
||||
echo "Notarizing DMG with Apple"
|
||||
/usr/bin/codesign --deep --force --timestamp --options runtime --sign "$IDENTITY" "$(pwd)/${dmg_file_path}" -v
|
||||
echo "$APPLE_NOTARIZATION_KEY" > "$notarization_key_file"
|
||||
"${xcode_bin_dir_path}/notarytool" submit --wait --key "$notarization_key_file" --key-id "$APPLE_NOTARIZATION_KEY_ID" --issuer "$APPLE_NOTARIZATION_ISSUER_ID" "${dmg_file_path}"
|
||||
rm "$notarization_key_file"
|
||||
"${xcode_bin_dir_path}/stapler" staple "${dmg_file_path}"
|
||||
fi
|
||||
|
||||
if [ "$open_result" = true ]; then
|
||||
open $dmg_target_directory
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
function sign_binary() {
|
||||
local binary_path=$1
|
||||
|
||||
if [[ $can_code_sign = true ]]; then
|
||||
echo "Code signing executable $binary_path"
|
||||
/usr/bin/codesign --deep --force --timestamp --options runtime --entitlements crates/zed/resources/zed.entitlements --sign "$IDENTITY" "${binary_path}" -v
|
||||
fi
|
||||
}
|
||||
|
||||
function upload_debug_symbols() {
|
||||
if [ "$local_install" = true ]; then
|
||||
echo "local install; skipping sentry upload."
|
||||
elif [[ -n "${SENTRY_AUTH_TOKEN:-}" ]]; then
|
||||
echo "Uploading zed debug symbols to sentry..."
|
||||
exe_path="target/${target_triple}/release/Zed"
|
||||
if ! dsymutil --flat "target/${target_triple}/${target_dir}/zed" 2> target/dsymutil.log; then
|
||||
echo "dsymutil failed"
|
||||
cat target/dsymutil.log
|
||||
exit 1
|
||||
fi
|
||||
if ! dsymutil --flat "target/${target_triple}/${target_dir}/remote_server" 2> target/dsymutil.log; then
|
||||
echo "dsymutil failed"
|
||||
cat target/dsymutil.log
|
||||
exit 1
|
||||
fi
|
||||
# note: this uploads the unstripped binary which is needed because it contains
|
||||
# .eh_frame data for stack unwinding. see https://github.com/getsentry/symbolic/issues/783
|
||||
# Try uploading up to 3 times
|
||||
for attempt in 1 2 3; do
|
||||
echo "Sentry upload attempt $attempt..."
|
||||
if sentry-cli debug-files upload --include-sources --wait -p zed -o zed-dev \
|
||||
"target/${target_triple}/${target_dir}/zed.dwarf" \
|
||||
"target/${target_triple}/${target_dir}/remote_server.dwarf"; then
|
||||
break
|
||||
else
|
||||
echo "Sentry upload failed on attempt $attempt"
|
||||
if [ $attempt -eq 3 ]; then
|
||||
echo "All sentry upload attempts failed"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "missing SENTRY_AUTH_TOKEN. skipping sentry upload."
|
||||
fi
|
||||
}
|
||||
|
||||
upload_debug_symbols
|
||||
|
||||
cp target/${target_triple}/${target_dir}/zed "${app_path}/Contents/MacOS/zed"
|
||||
cp target/${target_triple}/${target_dir}/cli "${app_path}/Contents/MacOS/cli"
|
||||
sign_app_binaries
|
||||
|
||||
sign_binary "target/$target_triple/release/remote_server"
|
||||
gzip -f --stdout --best target/$target_triple/release/remote_server > target/zed-remote-server-macos-$arch_suffix.gz
|
||||
396
script/bundle-windows.ps1
Normal file
396
script/bundle-windows.ps1
Normal file
@@ -0,0 +1,396 @@
|
||||
[CmdletBinding()]
|
||||
Param(
|
||||
[Parameter()][Alias('i')][switch]$Install,
|
||||
[Parameter()][Alias('h')][switch]$Help,
|
||||
[Parameter()][Alias('a')][string]$Architecture,
|
||||
[Parameter()][string]$Name
|
||||
)
|
||||
|
||||
. "$PSScriptRoot/lib/workspace.ps1"
|
||||
|
||||
# https://stackoverflow.com/questions/57949031/powershell-script-stops-if-program-fails-like-bash-set-o-errexit
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $true
|
||||
|
||||
$buildSuccess = $false
|
||||
|
||||
$OSArchitecture = switch ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture) {
|
||||
"X64" { "x86_64" }
|
||||
"Arm64" { "aarch64" }
|
||||
default { throw "Unsupported architecture" }
|
||||
}
|
||||
|
||||
$Architecture = if ($Architecture) {
|
||||
$Architecture
|
||||
} else {
|
||||
$OSArchitecture
|
||||
}
|
||||
|
||||
$CargoOutDir = "./target/$Architecture-pc-windows-msvc/release"
|
||||
|
||||
function Get-VSArch {
|
||||
param(
|
||||
[string]$Arch
|
||||
)
|
||||
|
||||
switch ($Arch) {
|
||||
"x86_64" { "amd64" }
|
||||
"aarch64" { "arm64" }
|
||||
}
|
||||
}
|
||||
|
||||
Push-Location
|
||||
& "C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\Tools\Launch-VsDevShell.ps1" -Arch (Get-VSArch -Arch $Architecture) -HostArch (Get-VSArch -Arch $OSArchitecture)
|
||||
Pop-Location
|
||||
|
||||
$target = "$Architecture-pc-windows-msvc"
|
||||
|
||||
if ($Help) {
|
||||
Write-Output "Usage: test.ps1 [-Install] [-Help]"
|
||||
Write-Output "Build the installer for Windows.\n"
|
||||
Write-Output "Options:"
|
||||
Write-Output " -Architecture, -a Which architecture to build (x86_64 or aarch64)"
|
||||
Write-Output " -Install, -i Run the installer after building."
|
||||
Write-Output " -Help, -h Show this help message."
|
||||
exit 0
|
||||
}
|
||||
|
||||
Push-Location -Path crates/zed
|
||||
$channel = Get-Content "RELEASE_CHANNEL"
|
||||
$env:ZED_RELEASE_CHANNEL = $channel
|
||||
$env:RELEASE_CHANNEL = $channel
|
||||
Pop-Location
|
||||
|
||||
function CheckEnvironmentVariables {
|
||||
if(-not $env:CI) {
|
||||
return
|
||||
}
|
||||
|
||||
$requiredVars = @(
|
||||
'ZED_WORKSPACE', 'RELEASE_VERSION', 'ZED_RELEASE_CHANNEL',
|
||||
'AZURE_TENANT_ID', 'AZURE_CLIENT_ID', 'AZURE_CLIENT_SECRET',
|
||||
'ACCOUNT_NAME', 'CERT_PROFILE_NAME', 'ENDPOINT',
|
||||
'FILE_DIGEST', 'TIMESTAMP_DIGEST', 'TIMESTAMP_SERVER'
|
||||
)
|
||||
|
||||
foreach ($var in $requiredVars) {
|
||||
if (-not (Test-Path "env:$var")) {
|
||||
Write-Error "$var is not set"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function PrepareForBundle {
|
||||
if (Test-Path "$innoDir") {
|
||||
Remove-Item -Path "$innoDir" -Recurse -Force
|
||||
}
|
||||
New-Item -Path "$innoDir" -ItemType Directory -Force
|
||||
Copy-Item -Path "$env:ZED_WORKSPACE\crates\zed\resources\windows\*" -Destination "$innoDir" -Recurse -Force
|
||||
New-Item -Path "$innoDir\make_appx" -ItemType Directory -Force
|
||||
New-Item -Path "$innoDir\appx" -ItemType Directory -Force
|
||||
New-Item -Path "$innoDir\bin" -ItemType Directory -Force
|
||||
New-Item -Path "$innoDir\tools" -ItemType Directory -Force
|
||||
|
||||
rustup target add $target
|
||||
}
|
||||
|
||||
function GenerateLicenses {
|
||||
. $PSScriptRoot/generate-licenses.ps1
|
||||
}
|
||||
|
||||
function BuildZedAndItsFriends {
|
||||
Write-Output "Building Zed and its friends, for channel: $channel"
|
||||
# Build zed.exe, cli.exe and auto_update_helper.exe
|
||||
cargo build --release --package zed --package cli --package auto_update_helper --target $target
|
||||
Copy-Item -Path ".\$CargoOutDir\zed.exe" -Destination "$innoDir\Zed.exe" -Force
|
||||
Copy-Item -Path ".\$CargoOutDir\cli.exe" -Destination "$innoDir\cli.exe" -Force
|
||||
Copy-Item -Path ".\$CargoOutDir\auto_update_helper.exe" -Destination "$innoDir\auto_update_helper.exe" -Force
|
||||
# Build explorer_command_injector.dll
|
||||
switch ($channel) {
|
||||
"stable" {
|
||||
cargo build --release --features stable --no-default-features --package explorer_command_injector --target $target
|
||||
}
|
||||
"preview" {
|
||||
cargo build --release --features preview --no-default-features --package explorer_command_injector --target $target
|
||||
}
|
||||
default {
|
||||
cargo build --release --package explorer_command_injector --target $target
|
||||
}
|
||||
}
|
||||
Copy-Item -Path ".\$CargoOutDir\explorer_command_injector.dll" -Destination "$innoDir\zed_explorer_command_injector.dll" -Force
|
||||
}
|
||||
|
||||
function BuildRemoteServer {
|
||||
Write-Output "Building remote_server for $target"
|
||||
cargo build --release --package remote_server --target $target
|
||||
|
||||
# Create zipped remote server binary
|
||||
$remoteServerSrc = (Resolve-Path ".\$CargoOutDir\remote_server.exe").Path
|
||||
|
||||
if ($env:CI) {
|
||||
Write-Output "Code signing remote_server.exe"
|
||||
& "$innoDir\sign.ps1" $remoteServerSrc
|
||||
}
|
||||
|
||||
$remoteServerDst = "$env:ZED_WORKSPACE\target\zed-remote-server-windows-$Architecture.zip"
|
||||
Write-Output "Compressing remote_server to $remoteServerDst"
|
||||
Compress-Archive -Path $remoteServerSrc -DestinationPath $remoteServerDst -Force
|
||||
|
||||
Write-Output "Remote server compressed successfully"
|
||||
}
|
||||
|
||||
function ZipZedAndItsFriendsDebug {
|
||||
$items = @(
|
||||
".\$CargoOutDir\zed.pdb",
|
||||
".\$CargoOutDir\cli.pdb",
|
||||
".\$CargoOutDir\auto_update_helper.pdb",
|
||||
".\$CargoOutDir\explorer_command_injector.pdb",
|
||||
".\$CargoOutDir\remote_server.pdb"
|
||||
)
|
||||
|
||||
Compress-Archive -Path $items -DestinationPath ".\$CargoOutDir\zed-$env:RELEASE_VERSION-$env:ZED_RELEASE_CHANNEL.dbg.zip" -Force
|
||||
}
|
||||
|
||||
|
||||
function UploadToSentry {
|
||||
if (-not (Get-Command "sentry-cli" -ErrorAction SilentlyContinue)) {
|
||||
Write-Output "sentry-cli not found. skipping sentry upload."
|
||||
Write-Output "install with: 'winget install -e --id=Sentry.sentry-cli'"
|
||||
return
|
||||
}
|
||||
if (-not (Test-Path "env:SENTRY_AUTH_TOKEN")) {
|
||||
Write-Output "missing SENTRY_AUTH_TOKEN. skipping sentry upload."
|
||||
return
|
||||
}
|
||||
Write-Output "Uploading zed debug symbols to sentry..."
|
||||
for ($i = 1; $i -le 3; $i++) {
|
||||
try {
|
||||
sentry-cli debug-files upload --include-sources --wait -p zed -o zed-dev $CargoOutDir
|
||||
break
|
||||
}
|
||||
catch {
|
||||
Write-Output "Sentry upload attempt $i failed: $_"
|
||||
if ($i -eq 3) {
|
||||
Write-Output "All sentry upload attempts failed"
|
||||
throw
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function MakeAppx {
|
||||
switch ($channel) {
|
||||
"stable" {
|
||||
$manifestFile = "$env:ZED_WORKSPACE\crates\explorer_command_injector\AppxManifest.xml"
|
||||
}
|
||||
"preview" {
|
||||
$manifestFile = "$env:ZED_WORKSPACE\crates\explorer_command_injector\AppxManifest-Preview.xml"
|
||||
}
|
||||
default {
|
||||
$manifestFile = "$env:ZED_WORKSPACE\crates\explorer_command_injector\AppxManifest-Nightly.xml"
|
||||
}
|
||||
}
|
||||
Copy-Item -Path "$manifestFile" -Destination "$innoDir\make_appx\AppxManifest.xml"
|
||||
# Add makeAppx.exe to Path
|
||||
$sdk = "C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\x64"
|
||||
$env:Path += ';' + $sdk
|
||||
makeAppx.exe pack /d "$innoDir\make_appx" /p "$innoDir\zed_explorer_command_injector.appx" /nv
|
||||
}
|
||||
|
||||
function SignZedAndItsFriends {
|
||||
if (-not $env:CI) {
|
||||
return
|
||||
}
|
||||
|
||||
$files = "$innoDir\Zed.exe,$innoDir\cli.exe,$innoDir\auto_update_helper.exe,$innoDir\zed_explorer_command_injector.dll,$innoDir\zed_explorer_command_injector.appx"
|
||||
& "$innoDir\sign.ps1" $files
|
||||
}
|
||||
|
||||
function DownloadAMDGpuServices {
|
||||
# If you update the AGS SDK version, please also update the version in `crates/gpui/src/platform/windows/directx_renderer.rs`
|
||||
$url = "https://codeload.github.com/GPUOpen-LibrariesAndSDKs/AGS_SDK/zip/refs/tags/v6.3.0"
|
||||
$zipPath = ".\AGS_SDK_v6.3.0.zip"
|
||||
# Download the AGS SDK zip file
|
||||
Invoke-WebRequest -Uri $url -OutFile $zipPath
|
||||
# Extract the AGS SDK zip file
|
||||
Expand-Archive -Path $zipPath -DestinationPath "." -Force
|
||||
}
|
||||
|
||||
function DownloadConpty {
|
||||
$url = "https://github.com/microsoft/terminal/releases/download/v1.23.13503.0/Microsoft.Windows.Console.ConPTY.1.23.251216003.nupkg"
|
||||
$zipPath = ".\Microsoft.Windows.Console.ConPTY.1.23.251216003.nupkg"
|
||||
Invoke-WebRequest -Uri $url -OutFile $zipPath
|
||||
Expand-Archive -Path $zipPath -DestinationPath ".\conpty" -Force
|
||||
}
|
||||
|
||||
function CollectFiles {
|
||||
Move-Item -Path "$innoDir\zed_explorer_command_injector.appx" -Destination "$innoDir\appx\zed_explorer_command_injector.appx" -Force
|
||||
Move-Item -Path "$innoDir\zed_explorer_command_injector.dll" -Destination "$innoDir\appx\zed_explorer_command_injector.dll" -Force
|
||||
Move-Item -Path "$innoDir\cli.exe" -Destination "$innoDir\bin\zed.exe" -Force
|
||||
Move-Item -Path "$innoDir\zed.sh" -Destination "$innoDir\bin\zed" -Force
|
||||
Move-Item -Path "$innoDir\auto_update_helper.exe" -Destination "$innoDir\tools\auto_update_helper.exe" -Force
|
||||
if($Architecture -eq "aarch64") {
|
||||
New-Item -Type Directory -Path "$innoDir\arm64" -Force
|
||||
Move-Item -Path ".\conpty\build\native\runtimes\arm64\OpenConsole.exe" -Destination "$innoDir\arm64\OpenConsole.exe" -Force
|
||||
Move-Item -Path ".\conpty\runtimes\win-arm64\native\conpty.dll" -Destination "$innoDir\conpty.dll" -Force
|
||||
}
|
||||
else {
|
||||
New-Item -Type Directory -Path "$innoDir\x64" -Force
|
||||
New-Item -Type Directory -Path "$innoDir\arm64" -Force
|
||||
Move-Item -Path ".\AGS_SDK-6.3.0\ags_lib\lib\amd_ags_x64.dll" -Destination "$innoDir\amd_ags_x64.dll" -Force
|
||||
Move-Item -Path ".\conpty\build\native\runtimes\x64\OpenConsole.exe" -Destination "$innoDir\x64\OpenConsole.exe" -Force
|
||||
Move-Item -Path ".\conpty\build\native\runtimes\arm64\OpenConsole.exe" -Destination "$innoDir\arm64\OpenConsole.exe" -Force
|
||||
Move-Item -Path ".\conpty\runtimes\win-x64\native\conpty.dll" -Destination "$innoDir\conpty.dll" -Force
|
||||
}
|
||||
}
|
||||
|
||||
function BuildInstaller {
|
||||
$issFilePath = "$innoDir\zed.iss"
|
||||
switch ($channel) {
|
||||
"stable" {
|
||||
$appId = "{{2DB0DA96-CA55-49BB-AF4F-64AF36A86712}"
|
||||
$appIconName = "app-icon"
|
||||
$appName = "Zed"
|
||||
$appDisplayName = "Zed"
|
||||
$appSetupName = "Zed-$Architecture"
|
||||
# The mutex name here should match the mutex name in crates\zed\src\zed\windows_only_instance.rs
|
||||
$appMutex = "Zed-Stable-Instance-Mutex"
|
||||
$appExeName = "Zed"
|
||||
$regValueName = "Zed"
|
||||
$appUserId = "ZedIndustries.Zed"
|
||||
$appShellNameShort = "Z&ed"
|
||||
$appAppxFullName = "ZedIndustries.Zed_1.0.0.0_neutral__japxn1gcva8rg"
|
||||
}
|
||||
"preview" {
|
||||
$appId = "{{F70E4811-D0E2-4D88-AC99-D63752799F95}"
|
||||
$appIconName = "app-icon-preview"
|
||||
$appName = "Zed Preview"
|
||||
$appDisplayName = "Zed Preview"
|
||||
$appSetupName = "Zed-$Architecture"
|
||||
# The mutex name here should match the mutex name in crates\zed\src\zed\windows_only_instance.rs
|
||||
$appMutex = "Zed-Preview-Instance-Mutex"
|
||||
$appExeName = "Zed"
|
||||
$regValueName = "ZedPreview"
|
||||
$appUserId = "ZedIndustries.Zed.Preview"
|
||||
$appShellNameShort = "Z&ed Preview"
|
||||
$appAppxFullName = "ZedIndustries.Zed.Preview_1.0.0.0_neutral__japxn1gcva8rg"
|
||||
}
|
||||
"nightly" {
|
||||
$appId = "{{1BDB21D3-14E7-433C-843C-9C97382B2FE0}"
|
||||
$appIconName = "app-icon-nightly"
|
||||
$appName = "Zed Nightly"
|
||||
$appDisplayName = "Zed Nightly"
|
||||
$appSetupName = "Zed-$Architecture"
|
||||
# The mutex name here should match the mutex name in crates\zed\src\zed\windows_only_instance.rs
|
||||
$appMutex = "Zed-Nightly-Instance-Mutex"
|
||||
$appExeName = "Zed"
|
||||
$regValueName = "ZedNightly"
|
||||
$appUserId = "ZedIndustries.Zed.Nightly"
|
||||
$appShellNameShort = "Z&ed Editor Nightly"
|
||||
$appAppxFullName = "ZedIndustries.Zed.Nightly_1.0.0.0_neutral__japxn1gcva8rg"
|
||||
}
|
||||
"dev" {
|
||||
$appId = "{{8357632E-24A4-4F32-BA97-E575B4D1FE5D}"
|
||||
$appIconName = "app-icon-dev"
|
||||
$appName = "Zed Dev"
|
||||
$appDisplayName = "Zed Dev"
|
||||
$appSetupName = "Zed-$Architecture"
|
||||
# The mutex name here should match the mutex name in crates\zed\src\zed\windows_only_instance.rs
|
||||
$appMutex = "Zed-Dev-Instance-Mutex"
|
||||
$appExeName = "Zed"
|
||||
$regValueName = "ZedDev"
|
||||
$appUserId = "ZedIndustries.Zed.Dev"
|
||||
$appShellNameShort = "Z&ed Dev"
|
||||
$appAppxFullName = "ZedIndustries.Zed.Dev_1.0.0.0_neutral__japxn1gcva8rg"
|
||||
}
|
||||
default {
|
||||
Write-Error "can't bundle installer for $channel."
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Windows runner 2022 default has iscc in PATH, https://github.com/actions/runner-images/blob/main/images/windows/Windows2022-Readme.md
|
||||
# Currently, we are using Windows 2022 runner.
|
||||
# Windows runner 2025 doesn't have iscc in PATH for now, https://github.com/actions/runner-images/issues/11228
|
||||
$innoSetupPath = "C:\Program Files (x86)\Inno Setup 6\ISCC.exe"
|
||||
|
||||
$definitions = @{
|
||||
"AppId" = $appId
|
||||
"AppIconName" = $appIconName
|
||||
"OutputDir" = "$env:ZED_WORKSPACE\target"
|
||||
"AppSetupName" = $appSetupName
|
||||
"AppName" = $appName
|
||||
"AppDisplayName" = $appDisplayName
|
||||
"RegValueName" = $regValueName
|
||||
"AppMutex" = $appMutex
|
||||
"AppExeName" = $appExeName
|
||||
"ResourcesDir" = "$innoDir"
|
||||
"ShellNameShort" = $appShellNameShort
|
||||
"AppUserId" = $appUserId
|
||||
"Version" = "$env:RELEASE_VERSION"
|
||||
"SourceDir" = "$env:ZED_WORKSPACE"
|
||||
"AppxFullName" = $appAppxFullName
|
||||
}
|
||||
|
||||
$defs = @()
|
||||
foreach ($key in $definitions.Keys) {
|
||||
$defs += "/d$key=`"$($definitions[$key])`""
|
||||
}
|
||||
|
||||
$innoArgs = @($issFilePath) + $defs
|
||||
if($env:CI) {
|
||||
$signTool = "powershell.exe -ExecutionPolicy Bypass -File $innoDir\sign.ps1 `$f"
|
||||
$innoArgs += "/sDefaultsign=`"$signTool`""
|
||||
}
|
||||
|
||||
# Execute Inno Setup
|
||||
Write-Host "🚀 Running Inno Setup: $innoSetupPath $innoArgs"
|
||||
$process = Start-Process -FilePath $innoSetupPath -ArgumentList $innoArgs -NoNewWindow -Wait -PassThru
|
||||
|
||||
if ($process.ExitCode -eq 0) {
|
||||
Write-Host "✅ Inno Setup successfully compiled the installer"
|
||||
Write-Output "SETUP_PATH=target/$appSetupName.exe" >> $env:GITHUB_ENV
|
||||
$script:buildSuccess = $true
|
||||
}
|
||||
else {
|
||||
Write-Host "❌ Inno Setup failed: $($process.ExitCode)"
|
||||
$script:buildSuccess = $false
|
||||
}
|
||||
}
|
||||
|
||||
ParseZedWorkspace
|
||||
$innoDir = "$env:ZED_WORKSPACE\inno\$Architecture"
|
||||
$debugArchive = "$CargoOutDir\zed-$env:RELEASE_VERSION-$env:ZED_RELEASE_CHANNEL.dbg.zip"
|
||||
$debugStoreKey = "$env:ZED_RELEASE_CHANNEL/zed-$env:RELEASE_VERSION-$env:ZED_RELEASE_CHANNEL.dbg.zip"
|
||||
|
||||
CheckEnvironmentVariables
|
||||
PrepareForBundle
|
||||
GenerateLicenses
|
||||
BuildZedAndItsFriends
|
||||
BuildRemoteServer
|
||||
MakeAppx
|
||||
SignZedAndItsFriends
|
||||
ZipZedAndItsFriendsDebug
|
||||
DownloadAMDGpuServices
|
||||
DownloadConpty
|
||||
CollectFiles
|
||||
BuildInstaller
|
||||
|
||||
if($env:CI) {
|
||||
UploadToSentry
|
||||
}
|
||||
|
||||
if ($buildSuccess) {
|
||||
Write-Output "Build successful"
|
||||
if ($Install) {
|
||||
Write-Output "Installing Zed..."
|
||||
Start-Process -FilePath "$env:ZED_WORKSPACE/target/ZedEditorUserSetup-x64-$env:RELEASE_VERSION.exe"
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
else {
|
||||
Write-Output "Build failed"
|
||||
exit 1
|
||||
}
|
||||
322
script/cargo
Executable file
322
script/cargo
Executable file
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// ./script/cargo is a transparent wrapper around cargo that:
|
||||
// - When running in a clone of `./zed-industries/zed`
|
||||
// - outputs build timings to the ZED_DATA_DIR/build_timings
|
||||
// When Zed starts for staff-members it uploads the build timings to Snowflake
|
||||
// To use it:
|
||||
// ./script/cargo --init
|
||||
// This will add a wrapper to your shell configuration files.
|
||||
// (Otherwise set up an alias `cargo=PATH_TO_THIS_FILE`)
|
||||
|
||||
// We need to ignore SIGINT in this process so that we can continue
|
||||
// processing timing files after the child cargo process exits.
|
||||
// The signal will still be delivered to the child process.
|
||||
process.on("SIGINT", () => {});
|
||||
|
||||
const { spawn, spawnSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const readline = require("readline");
|
||||
|
||||
const SUBCOMMANDS_WITH_TIMINGS = new Set(["build", "check", "run", "test"]);
|
||||
|
||||
// Built-in cargo aliases
|
||||
const CARGO_ALIASES = {
|
||||
b: "build",
|
||||
c: "check",
|
||||
t: "test",
|
||||
r: "run",
|
||||
d: "doc",
|
||||
};
|
||||
|
||||
function expandAlias(subcommand) {
|
||||
return CARGO_ALIASES[subcommand] || subcommand;
|
||||
}
|
||||
|
||||
function detectShell() {
|
||||
// Check for PowerShell first (works when running from pwsh)
|
||||
if (process.env.PSModulePath && !process.env.BASH_VERSION) {
|
||||
return "powershell";
|
||||
}
|
||||
|
||||
const shell = process.env.SHELL || "";
|
||||
if (shell.endsWith("/zsh")) return "zsh";
|
||||
if (shell.endsWith("/bash")) return "bash";
|
||||
if (shell.endsWith("/fish")) return "fish";
|
||||
if (shell.endsWith("/pwsh") || shell.endsWith("/powershell")) return "powershell";
|
||||
return path.basename(shell) || "unknown";
|
||||
}
|
||||
|
||||
function getShellConfigPath(shell) {
|
||||
const home = os.homedir();
|
||||
switch (shell) {
|
||||
case "zsh":
|
||||
return path.join(process.env.ZDOTDIR || home, ".zshrc");
|
||||
case "bash":
|
||||
// Prefer .bashrc, fall back to .bash_profile
|
||||
const bashrc = path.join(home, ".bashrc");
|
||||
if (fs.existsSync(bashrc)) return bashrc;
|
||||
return path.join(home, ".bash_profile");
|
||||
case "fish":
|
||||
return path.join(home, ".config", "fish", "config.fish");
|
||||
case "powershell":
|
||||
if (process.platform === "win32") {
|
||||
// Spawn PowerShell to get the real $PROFILE path, since os.homedir() doesn't account
|
||||
// for OneDrive folder redirection, and the subdirectory differs between Windows PowerShell
|
||||
// 5.x ("WindowsPowerShell") and PowerShell Core ("PowerShell").
|
||||
const psModulePath = process.env.PSModulePath || "";
|
||||
const psExe = psModulePath.toLowerCase().includes("\\windowspowershell\\") ? "powershell" : "pwsh";
|
||||
const result = spawnSync(psExe, ["-NoProfile", "-Command", "$PROFILE"], {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
timeout: 5000,
|
||||
});
|
||||
if (result.status === 0 && result.stdout.trim()) {
|
||||
return result.stdout.trim();
|
||||
}
|
||||
// Fallback if spawning fails
|
||||
return path.join(home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1");
|
||||
} else {
|
||||
return path.join(home, ".config", "powershell", "Microsoft.PowerShell_profile.ps1");
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function generateAlias(shell, scriptDir) {
|
||||
const cargoWrapper = path.join(scriptDir, "cargo");
|
||||
|
||||
switch (shell) {
|
||||
case "zsh":
|
||||
case "bash":
|
||||
return `\n# Zed cargo timing wrapper\ncargo() { local w="${cargoWrapper}"; if [[ -x "$w" ]]; then "$w" "$@"; else command cargo "$@"; fi; }\n`;
|
||||
case "fish":
|
||||
return `\n# Zed cargo timing wrapper\nfunction cargo\n set -l w "${cargoWrapper}"\n if test -x "$w"\n "$w" $argv\n return $status\n else\n command cargo $argv\n end\nend\n`;
|
||||
case "powershell":
|
||||
return `\n# Zed cargo timing wrapper\nfunction cargo {\n \$wrapper = "${cargoWrapper}"\n if (Test-Path \$wrapper) {\n node \$wrapper @args\n } else {\n & (Get-Command -Name cargo -CommandType Application | Select-Object -First 1).Source @args\n }\n}\n`;
|
||||
default:
|
||||
return `cargo() { local w="${cargoWrapper}"; if [[ -x "$w" ]]; then "$w" "$@"; else command cargo "$@"; fi; }`;
|
||||
}
|
||||
}
|
||||
|
||||
function aliasBlockRegex(shell) {
|
||||
switch (shell) {
|
||||
case "zsh":
|
||||
case "bash":
|
||||
// Comment line + single-line cargo() { ... } function
|
||||
return /\n?# Zed cargo timing wrapper\ncargo\(\) \{[^\n]*\}\n/;
|
||||
case "fish":
|
||||
// Comment line + multi-line function cargo...end block
|
||||
return /\n?# Zed cargo timing wrapper\nfunction cargo\n[\s\S]*?\nend\n/;
|
||||
case "powershell":
|
||||
// Comment line + multi-line function cargo {...} block
|
||||
return /\n?# Zed cargo timing wrapper\nfunction cargo \{[\s\S]*?\n\}\n/;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function initShellAlias() {
|
||||
const scriptDir = __dirname;
|
||||
const shell = detectShell();
|
||||
const configPath = getShellConfigPath(shell);
|
||||
const alias = generateAlias(shell, scriptDir);
|
||||
|
||||
if (!configPath) {
|
||||
console.log(`Unsupported shell: ${shell}`);
|
||||
console.log("\nAdd the following to your shell configuration:\n");
|
||||
console.log(alias);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if alias already exists; if so, replace it in-place
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
if (content.includes("Zed cargo timing wrapper")) {
|
||||
const blockRegex = aliasBlockRegex(shell);
|
||||
const updated = blockRegex ? content.replace(blockRegex, "") : content;
|
||||
fs.writeFileSync(configPath, updated + alias);
|
||||
console.log(`Updated cargo timing alias in ${configPath}`);
|
||||
if (shell === "powershell") {
|
||||
console.log(`\nRestart PowerShell or run: . "${configPath}"`);
|
||||
} else {
|
||||
console.log(`\nRestart your shell or run: source ${configPath}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Create parent directory if needed (for PowerShell on Linux/macOS)
|
||||
const configDir = path.dirname(configPath);
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Append alias to config file
|
||||
fs.appendFileSync(configPath, alias);
|
||||
console.log(`Added cargo timing alias to ${configPath}`);
|
||||
|
||||
if (shell === "powershell") {
|
||||
console.log(`\nRestart PowerShell or run: . "${configPath}"`);
|
||||
} else {
|
||||
console.log(`\nRestart your shell or run: source ${configPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isZedRepo() {
|
||||
try {
|
||||
const result = spawnSync("git", ["remote", "-v"], {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
timeout: 5000,
|
||||
});
|
||||
if (result.status !== 0 || !result.stdout) {
|
||||
return false;
|
||||
}
|
||||
return result.stdout.includes("zed-industries/zed");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findSubcommand(args) {
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
// Skip flags and their values
|
||||
if (arg.startsWith("-")) {
|
||||
// If this flag takes a value and it's not using = syntax, skip the next arg too
|
||||
if (!arg.includes("=") && i + 1 < args.length && !args[i + 1].startsWith("-")) {
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// First non-flag argument is the subcommand
|
||||
return { subcommand: arg, index: i };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findLatestTimingFile(targetDir) {
|
||||
const timingsDir = path.join(targetDir, "cargo-timings");
|
||||
if (!fs.existsSync(timingsDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const files = fs
|
||||
.readdirSync(timingsDir)
|
||||
.filter((f) => f.startsWith("cargo-timing-") && f.endsWith(".html") && f !== "cargo-timing.html")
|
||||
.map((f) => ({
|
||||
name: f,
|
||||
path: path.join(timingsDir, f),
|
||||
mtime: fs.statSync(path.join(timingsDir, f)).mtime.getTime(),
|
||||
}))
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
return files.length > 0 ? files[0].path : null;
|
||||
}
|
||||
|
||||
function getTargetDir(args) {
|
||||
// Check for --target-dir flag
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--target-dir" && i + 1 < args.length) {
|
||||
return args[i + 1];
|
||||
}
|
||||
if (args[i].startsWith("--target-dir=")) {
|
||||
return args[i].substring("--target-dir=".length);
|
||||
}
|
||||
}
|
||||
// Default target directory
|
||||
return "target";
|
||||
}
|
||||
|
||||
function runCargoPassthrough(args) {
|
||||
const cargoCmd = process.env.CARGO || "cargo";
|
||||
const result = spawnSync(cargoCmd, args, {
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Handle --init flag
|
||||
if (args[0] === "--init") {
|
||||
if (process.env.NIX_WRAPPER === "1") {
|
||||
console.error("`--init` not supported when going through the nix wrapper");
|
||||
process.exit(1);
|
||||
}
|
||||
initShellAlias();
|
||||
return;
|
||||
}
|
||||
|
||||
// If not in zed repo, just pass through to cargo
|
||||
if (!isZedRepo()) {
|
||||
runCargoPassthrough(args);
|
||||
return;
|
||||
}
|
||||
|
||||
const cargoCmd = process.env.CARGO || "cargo";
|
||||
const subcommandInfo = findSubcommand(args);
|
||||
const expandedSubcommand = subcommandInfo ? expandAlias(subcommandInfo.subcommand) : null;
|
||||
const shouldAddTimings = expandedSubcommand && SUBCOMMANDS_WITH_TIMINGS.has(expandedSubcommand);
|
||||
|
||||
// Build the final args array
|
||||
let finalArgs = [...args];
|
||||
if (shouldAddTimings) {
|
||||
// Check if --timings is already present
|
||||
const hasTimings = args.some((arg) => arg === "--timings" || arg.startsWith("--timings="));
|
||||
if (!hasTimings) {
|
||||
// Insert --timings after the subcommand
|
||||
finalArgs.splice(subcommandInfo.index + 1, 0, "--timings");
|
||||
}
|
||||
}
|
||||
|
||||
// Run cargo asynchronously so we can handle signals properly
|
||||
const child = spawn(cargoCmd, finalArgs, {
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
|
||||
// Wait for the child to exit
|
||||
const result = await new Promise((resolve) => {
|
||||
child.on("exit", (code, signal) => {
|
||||
resolve({ status: code, signal });
|
||||
});
|
||||
});
|
||||
|
||||
// If we added timings, try to process the timing file (regardless of cargo's exit status)
|
||||
if (shouldAddTimings) {
|
||||
const targetDir = getTargetDir(args);
|
||||
const timingFile = findLatestTimingFile(targetDir);
|
||||
|
||||
if (timingFile) {
|
||||
// Run cargo-timing-info.js in the background
|
||||
const scriptDir = __dirname;
|
||||
const timingScript = path.join(scriptDir, "cargo-timing-info.js");
|
||||
|
||||
if (fs.existsSync(timingScript)) {
|
||||
const timingChild = spawn(process.execPath, [timingScript, timingFile, `cargo ${expandedSubcommand}`], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
});
|
||||
timingChild.unref();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exit with cargo's exit code, or re-raise the signal if it was killed
|
||||
if (result.signal) {
|
||||
// Reset signal handler and re-raise so parent sees the signal
|
||||
process.removeAllListeners(result.signal);
|
||||
process.kill(process.pid, result.signal);
|
||||
} else {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
185
script/cargo-timing-info.js
Executable file
185
script/cargo-timing-info.js
Executable file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
function getZedDataDir() {
|
||||
const platform = process.platform;
|
||||
|
||||
if (platform === "darwin") {
|
||||
// macOS: ~/Library/Application Support/Zed
|
||||
return path.join(os.homedir(), "Library", "Application Support", "Zed");
|
||||
} else if (platform === "linux" || platform === "freebsd") {
|
||||
// Linux/FreeBSD: $FLATPAK_XDG_DATA_HOME or XDG_DATA_HOME/zed
|
||||
if (process.env.FLATPAK_XDG_DATA_HOME) {
|
||||
return path.join(process.env.FLATPAK_XDG_DATA_HOME, "zed");
|
||||
}
|
||||
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
|
||||
return path.join(xdgDataHome, "zed");
|
||||
} else if (platform === "win32") {
|
||||
// Windows: LocalAppData/Zed
|
||||
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
||||
return path.join(localAppData, "Zed");
|
||||
} else {
|
||||
// Fallback to XDG config dir
|
||||
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
||||
return path.join(xdgConfigHome, "zed");
|
||||
}
|
||||
}
|
||||
|
||||
function extractUnitData(htmlContent) {
|
||||
// Find the UNIT_DATA array in the file
|
||||
const unitDataMatch = htmlContent.match(/const\s+UNIT_DATA\s*=\s*(\[[\s\S]*?\]);/);
|
||||
if (!unitDataMatch) {
|
||||
throw new Error("Could not find UNIT_DATA in the file");
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(unitDataMatch[1]);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to parse UNIT_DATA as JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (seconds < 60) {
|
||||
return `${seconds.toFixed(2)}s`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds.toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function formatUnit(unit) {
|
||||
let name = `${unit.name} v${unit.version}`;
|
||||
if (unit.target && unit.target.trim()) {
|
||||
name += ` (${unit.target.trim()})`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function parseTimestampFromFilename(filePath) {
|
||||
const basename = path.basename(filePath);
|
||||
// Format: cargo-timing-20260219T161555.879263Z.html
|
||||
const match = basename.match(/cargo-timing-(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})\.(\d+)Z\.html/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const [, year, month, day, hour, minute, second, microseconds] = match;
|
||||
// Convert to ISO 8601 format
|
||||
const milliseconds = Math.floor(parseInt(microseconds) / 1000);
|
||||
return `${year}-${month}-${day}T${hour}:${minute}:${second}.${milliseconds.toString().padStart(3, "0")}Z`;
|
||||
}
|
||||
|
||||
function writeBuildTimingJson(filePath, durationMs, firstCrate, target, blockedMs, command) {
|
||||
const buildTimingsDir = path.join(getZedDataDir(), "build_timings");
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (!fs.existsSync(buildTimingsDir)) {
|
||||
fs.mkdirSync(buildTimingsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Parse timestamp from filename, or use file modification time as fallback
|
||||
let startedAt = parseTimestampFromFilename(filePath);
|
||||
if (!startedAt) {
|
||||
const stats = fs.statSync(filePath);
|
||||
startedAt = stats.mtime.toISOString();
|
||||
}
|
||||
|
||||
const buildTiming = {
|
||||
started_at: startedAt,
|
||||
duration_ms: durationMs,
|
||||
first_crate: firstCrate,
|
||||
target: target,
|
||||
blocked_ms: blockedMs,
|
||||
command: command,
|
||||
};
|
||||
|
||||
const jsonPath = path.join(buildTimingsDir, `build-timing-${startedAt}.json`);
|
||||
fs.writeFileSync(jsonPath, JSON.stringify(buildTiming, null, 2) + "\n");
|
||||
console.log(`\nWrote build timing JSON to: ${jsonPath}`);
|
||||
}
|
||||
|
||||
function analyzeTimings(filePath, command) {
|
||||
// Read the file
|
||||
const htmlContent = fs.readFileSync(filePath, "utf-8");
|
||||
|
||||
// Extract UNIT_DATA
|
||||
const unitData = extractUnitData(htmlContent);
|
||||
|
||||
if (unitData.length === 0) {
|
||||
console.log("No units found in UNIT_DATA");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the unit that finishes last (start + duration)
|
||||
let lastFinishingUnit = unitData[0];
|
||||
let maxEndTime = unitData[0].start + unitData[0].duration;
|
||||
|
||||
for (const unit of unitData) {
|
||||
const endTime = unit.start + unit.duration;
|
||||
if (endTime > maxEndTime) {
|
||||
maxEndTime = endTime;
|
||||
lastFinishingUnit = unit;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the first crate that had to be rebuilt (earliest start time)
|
||||
// Sort by start time to find the first one
|
||||
const sortedByStart = [...unitData].sort((a, b) => a.start - b.start);
|
||||
const firstRebuilt = sortedByStart[0];
|
||||
|
||||
// The minimum start time indicates time spent blocked (e.g. waiting for cargo lock)
|
||||
const blockedTime = firstRebuilt.start;
|
||||
|
||||
// Find the last item being built (the one that was still building when the build finished)
|
||||
// This is the unit with the latest end time (which we already found)
|
||||
const lastBuilding = lastFinishingUnit;
|
||||
|
||||
console.log("=== Cargo Timing Analysis ===\n");
|
||||
console.log(`File: ${path.basename(filePath)}\n`);
|
||||
console.log(`Total build time: ${formatTime(maxEndTime)}`);
|
||||
console.log(`Time blocked: ${formatTime(blockedTime)}`);
|
||||
console.log(`Total crates compiled: ${unitData.length}\n`);
|
||||
console.log(`First crate rebuilt: ${formatUnit(firstRebuilt)}`);
|
||||
console.log(` Started at: ${formatTime(firstRebuilt.start)}`);
|
||||
console.log(` Duration: ${formatTime(firstRebuilt.duration)}\n`);
|
||||
console.log(`Last item being built: ${formatUnit(lastBuilding)}`);
|
||||
console.log(` Started at: ${formatTime(lastBuilding.start)}`);
|
||||
console.log(` Duration: ${formatTime(lastBuilding.duration)}`);
|
||||
console.log(` Finished at: ${formatTime(lastBuilding.start + lastBuilding.duration)}`);
|
||||
|
||||
// Write JSON file for BuildTiming struct
|
||||
const durationMs = maxEndTime * 1000;
|
||||
const blockedMs = blockedTime * 1000;
|
||||
const firstCrateName = firstRebuilt.name;
|
||||
const targetName = lastBuilding.name;
|
||||
writeBuildTimingJson(filePath, durationMs, firstCrateName, targetName, blockedMs, command);
|
||||
}
|
||||
|
||||
// Main execution
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0) {
|
||||
console.error("Usage: cargo-timing-info.js <path-to-cargo-timing.html> [command]");
|
||||
console.error("");
|
||||
console.error("Example:");
|
||||
console.error(" cargo-timing-info.js target/cargo-timings/cargo-timing-20260219T161555.879263Z.html");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const filePath = args[0];
|
||||
const command = args[1] || null;
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`Error: File not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
analyzeTimings(filePath, command);
|
||||
} catch (e) {
|
||||
console.error(`Error: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
26
script/check-keymaps
Executable file
26
script/check-keymaps
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
pattern='cmd-'
|
||||
result=$(git grep --no-color --line-number --fixed-strings -e "$pattern" -- \
|
||||
'assets/keymaps/' \
|
||||
':(exclude)assets/keymaps/storybook.json' \
|
||||
':(exclude)assets/keymaps/default-macos.json' \
|
||||
':(exclude)assets/keymaps/macos/*.json' || true)
|
||||
|
||||
if [[ -n "${result}" ]]; then
|
||||
echo "${result}"
|
||||
echo "Error: Found 'cmd-' in non-macOS keymap files."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pattern='super-|win-|fn-'
|
||||
result=$(git grep --no-color --line-number --fixed-strings -e "$pattern" -- \
|
||||
'assets/keymaps/' || true)
|
||||
|
||||
if [[ -n "${result}" ]]; then
|
||||
echo "${result}"
|
||||
echo "Error: Found 'super-', 'win-', or 'fn-' in keymap files. Currently these aren't used."
|
||||
exit 1
|
||||
fi
|
||||
88
script/check-licenses
Executable file
88
script/check-licenses
Executable file
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
AGPL_CRATES=("collab")
|
||||
RELEASE_CRATES=("cli" "remote_server" "zed")
|
||||
|
||||
check_symlink_target () {
|
||||
local symlink_path="$1"
|
||||
local license_name="$2"
|
||||
|
||||
local target=$(readlink "$symlink_path")
|
||||
|
||||
local dir=$(dirname "$symlink_path")
|
||||
local depth=$(echo "$dir" | tr '/' '\n' | wc -l)
|
||||
local expected_prefix=""
|
||||
for ((i = 0; i < depth; i++)); do
|
||||
expected_prefix="../$expected_prefix"
|
||||
done
|
||||
local expected_target="${expected_prefix}${license_name}"
|
||||
|
||||
if [[ "$target" != "$expected_target" ]]; then
|
||||
echo "Error: $symlink_path points to '$target' but should point to '$expected_target'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -e "$symlink_path" ]]; then
|
||||
echo "Error: $symlink_path is a broken symlink (target '$target' does not exist)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_license () {
|
||||
local dir="$1"
|
||||
local allowed_licenses=()
|
||||
|
||||
local is_agpl=false
|
||||
for agpl_crate in "${AGPL_CRATES[@]}"; do
|
||||
if [[ "$dir" == "crates/$agpl_crate" ]]; then
|
||||
is_agpl=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$is_agpl" == true ]]; then
|
||||
allowed_licenses=("LICENSE-AGPL")
|
||||
else
|
||||
allowed_licenses=("LICENSE-GPL" "LICENSE-APACHE")
|
||||
fi
|
||||
|
||||
for license in "${allowed_licenses[@]}"; do
|
||||
if [[ -L "$dir/$license" ]]; then
|
||||
check_symlink_target "$dir/$license" "$license"
|
||||
return 0
|
||||
elif [[ -e "$dir/$license" ]]; then
|
||||
echo "Error: $dir/$license exists but is not a symlink."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$is_agpl" == true ]]; then
|
||||
echo "Error: $dir does not contain a LICENSE-AGPL symlink"
|
||||
else
|
||||
echo "Error: $dir does not contain a LICENSE-GPL or LICENSE-APACHE symlink"
|
||||
fi
|
||||
exit 1
|
||||
}
|
||||
|
||||
git ls-files "**/*/Cargo.toml" | while read -r cargo_toml; do
|
||||
check_license "$(dirname "$cargo_toml")"
|
||||
done
|
||||
|
||||
|
||||
# Make sure the AGPL server crates are included in the release tarball.
|
||||
for release_crate in "${RELEASE_CRATES[@]}"; do
|
||||
tree_output=$(cargo tree --package "$release_crate")
|
||||
for agpl_crate in "${AGPL_CRATES[@]}"; do
|
||||
# Look for lines that contain the crate name followed by " v" (version)
|
||||
# This matches patterns like "├── collab v0.44.0"
|
||||
if echo "$tree_output" | grep -E "(^|[^a-zA-Z_])${agpl_crate} v" > /dev/null; then
|
||||
echo "Error: crate '${agpl_crate}' is AGPL and is a dependency of crate '${release_crate}'." >&2
|
||||
echo "AGPL licensed code should not be used in the release distribution, only in servers." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
echo "check-licenses succeeded"
|
||||
32
script/check-links
Executable file
32
script/check-links
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [local|all] [--help]"
|
||||
echo " local Only check local links (default)"
|
||||
echo " all Check all links including remote ones"
|
||||
exit 1
|
||||
}
|
||||
|
||||
check_mode="local"
|
||||
if [ $# -eq 1 ]; then
|
||||
case "$1" in
|
||||
"local") check_mode="local" ;;
|
||||
"all") check_mode="all" ;;
|
||||
"--help") usage ;;
|
||||
*) echo "Invalid argument: $1" && usage ;;
|
||||
esac
|
||||
else
|
||||
usage
|
||||
fi
|
||||
|
||||
cargo install lychee
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [ "$check_mode" = "all" ]; then
|
||||
lychee --no-progress './docs/src/**/*'
|
||||
else
|
||||
lychee --exclude '^http' './docs/src/**/*'
|
||||
fi
|
||||
#
|
||||
14
script/check-todos
Executable file
14
script/check-todos
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Brackets are used around characters so these don't show up in normal search.
|
||||
pattern='tod[o]!|FIXM[E]'
|
||||
result=$(git grep --no-color --ignore-case --line-number --extended-regexp -e $pattern -- \
|
||||
':(exclude).github/workflows/ci.yml' \
|
||||
':(exclude)*criteria.md' \
|
||||
':(exclude)*prompt.md' || true)
|
||||
if [[ -n "${result}" ]]; then
|
||||
echo "${result}"
|
||||
exit 1
|
||||
fi
|
||||
33
script/cherry-pick
Executable file
33
script/cherry-pick
Executable file
@@ -0,0 +1,33 @@
|
||||
# #!/bin/bash
|
||||
set -euxo pipefail
|
||||
|
||||
if [ "$#" -ne 3 ]; then
|
||||
echo "Usage: $0 <branch-name> <commit-sha> <channel>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BRANCH_NAME="$1"
|
||||
COMMIT_SHA="$2"
|
||||
CHANNEL="$3"
|
||||
|
||||
SHORT_SHA="${COMMIT_SHA:0:8}"
|
||||
NEW_BRANCH="cherry-pick-${BRANCH_NAME}-${SHORT_SHA}"
|
||||
git fetch --depth 2 origin +${COMMIT_SHA} ${BRANCH_NAME}
|
||||
git checkout --force "origin/$BRANCH_NAME" -B "$NEW_BRANCH"
|
||||
|
||||
git cherry-pick "$COMMIT_SHA"
|
||||
|
||||
git push origin -f "$NEW_BRANCH"
|
||||
COMMIT_TITLE=$(git log -1 --pretty=format:"%s" "$COMMIT_SHA")
|
||||
COMMIT_BODY=$(git log -1 --pretty=format:"%b" "$COMMIT_SHA")
|
||||
|
||||
# Check if commit title ends with (#number)
|
||||
if [[ "$COMMIT_TITLE" =~ \(#([0-9]+)\)$ ]]; then
|
||||
PR_NUMBER="${BASH_REMATCH[1]}"
|
||||
PR_BODY="Cherry-pick of #${PR_NUMBER} to ${CHANNEL}"$'\n'$'\n'"----"$'\n'"${COMMIT_BODY}"
|
||||
else
|
||||
PR_BODY="Cherry-pick of ${COMMIT_SHA} to ${CHANNEL}"$'\n'$'\n'"----"$'\n'"${COMMIT_BODY}"
|
||||
fi
|
||||
|
||||
# Create a pull request
|
||||
gh pr create --base "$BRANCH_NAME" --head "$NEW_BRANCH" --title "$COMMIT_TITLE (cherry-pick to $CHANNEL)" --body "$PR_BODY"
|
||||
35
script/clear-target-dir-if-larger-than
Executable file
35
script/clear-target-dir-if-larger-than
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
if [[ $# -lt 1 || $# -gt 2 ]]; then
|
||||
echo "usage: $0 <MAX_SIZE_IN_GB> [SMALL_CLEAN_SIZE_IN_GB]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ -d target ]]; then
|
||||
echo "target directory does not exist yet"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
max_size_gb=$1
|
||||
small_clean_size_gb=${2:-}
|
||||
|
||||
if [[ -n "${small_clean_size_gb}" && ${small_clean_size_gb} -ge ${max_size_gb} ]]; then
|
||||
echo "error: small clean threshold (${small_clean_size_gb}gb) must be smaller than max size (${max_size_gb}gb)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
current_size=$(du -s target | cut -f1)
|
||||
current_size_gb=$(( ${current_size} / 1024 / 1024 ))
|
||||
|
||||
echo "target directory size: ${current_size_gb}gb. max size: ${max_size_gb}gb"
|
||||
|
||||
if [[ ${current_size_gb} -gt ${max_size_gb} ]]; then
|
||||
echo "clearing target directory"
|
||||
shopt -s dotglob
|
||||
rm -rf target/*
|
||||
elif [[ -n "${small_clean_size_gb}" && ${current_size_gb} -gt ${small_clean_size_gb} ]]; then
|
||||
echo "running cargo clean --workspace (size above small clean threshold of ${small_clean_size_gb}gb)"
|
||||
cargo clean --workspace
|
||||
fi
|
||||
32
script/clear-target-dir-if-larger-than.ps1
Normal file
32
script/clear-target-dir-if-larger-than.ps1
Normal file
@@ -0,0 +1,32 @@
|
||||
param (
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$MAX_SIZE_IN_GB,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$SMALL_CLEAN_SIZE_IN_GB = -1
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$PSNativeCommandUseErrorActionPreference = $true
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
if (-Not (Test-Path -Path "target")) {
|
||||
Write-Host "target directory does not exist yet"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($SMALL_CLEAN_SIZE_IN_GB -ge 0 -and $SMALL_CLEAN_SIZE_IN_GB -ge $MAX_SIZE_IN_GB) {
|
||||
Write-Host "error: small clean threshold (${SMALL_CLEAN_SIZE_IN_GB}GB) must be smaller than max size (${MAX_SIZE_IN_GB}GB)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$current_size_gb = (Get-ChildItem -Recurse -Force -File -Path "target" | Measure-Object -Property Length -Sum).Sum / 1GB
|
||||
|
||||
Write-Host "target directory size: ${current_size_gb}GB. max size: ${MAX_SIZE_IN_GB}GB"
|
||||
|
||||
if ($current_size_gb -gt $MAX_SIZE_IN_GB) {
|
||||
Write-Host "clearing target directory"
|
||||
Remove-Item -Recurse -Force -Path "target\*" -ErrorAction SilentlyContinue
|
||||
} elseif ($SMALL_CLEAN_SIZE_IN_GB -ge 0 -and $current_size_gb -gt $SMALL_CLEAN_SIZE_IN_GB) {
|
||||
Write-Host "running cargo clean --workspace (size above small clean threshold of ${SMALL_CLEAN_SIZE_IN_GB}GB)"
|
||||
cargo clean --workspace
|
||||
}
|
||||
23
script/clippy
Executable file
23
script/clippy
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! " $* " == *" -p "* && ! " $* " == *" --package "* ]]; then
|
||||
set -- "$@" --workspace
|
||||
fi
|
||||
|
||||
set -x
|
||||
"${CARGO:-cargo}" clippy "$@" --release --all-targets --all-features -- --deny warnings
|
||||
|
||||
# If local, run other checks if we have the tools installed.
|
||||
if [[ -z "${GITHUB_ACTIONS+x}" ]]; then
|
||||
which cargo-machete >/dev/null 2>&1 || exit 0
|
||||
cargo machete
|
||||
|
||||
which typos >/dev/null 2>&1 || exit 0
|
||||
typos --config typos.toml
|
||||
|
||||
which buf >/dev/null 2>&1 || exit 0
|
||||
buf lint crates/proto/proto
|
||||
buf format --diff --exit-code crates/proto/proto
|
||||
fi
|
||||
32
script/clippy.ps1
Normal file
32
script/clippy.ps1
Normal file
@@ -0,0 +1,32 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "Your PATH entries:"
|
||||
$env:Path -split ";" | ForEach-Object { Write-Host " $_" }
|
||||
|
||||
$needAddWorkspace = $false
|
||||
if ($args -notcontains "-p" -and $args -notcontains "--package")
|
||||
{
|
||||
$needAddWorkspace = $true
|
||||
}
|
||||
|
||||
# https://stackoverflow.com/questions/41324882/how-to-run-a-powershell-script-with-verbose-output/70020655#70020655
|
||||
# Set-PSDebug -Trace 2
|
||||
|
||||
if ($env:CARGO)
|
||||
{
|
||||
$Cargo = $env:CARGO
|
||||
} elseif (Get-Command "cargo" -ErrorAction SilentlyContinue)
|
||||
{
|
||||
$Cargo = "cargo"
|
||||
} else
|
||||
{
|
||||
Write-Error "Could not find cargo in path." -ErrorAction Stop
|
||||
}
|
||||
|
||||
if ($needAddWorkspace)
|
||||
{
|
||||
& $Cargo clippy @args --workspace --release --all-targets --all-features -- --deny warnings
|
||||
} else
|
||||
{
|
||||
& $Cargo clippy @args --release --all-targets --all-features -- --deny warnings
|
||||
}
|
||||
33
script/collab-flamegraph
Executable file
33
script/collab-flamegraph
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Notes for fixing this script if it's broken:
|
||||
# - if you see an error about "can't find perf_6.1" you need to install `linux-perf` from the
|
||||
# version of Debian that matches the host (e.g. apt-get -t bookworm-backports install linux-perf)
|
||||
# - if you see an error about `addr2line` you may need to install binutils
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source script/lib/deploy-helpers.sh
|
||||
|
||||
if [[ $# != 1 ]]; then
|
||||
echo "Usage: $0 <production|staging>"
|
||||
exit 1
|
||||
fi
|
||||
environment=$1
|
||||
|
||||
target_zed_kube_cluster
|
||||
|
||||
# 5s in production is ~200Mb..., in staging you probably want to bump this up.
|
||||
echo "Running perf on collab, collecting 5s of data..."
|
||||
|
||||
kubectl -n $environment exec -it deployments/collab -- perf record -p 1 -g -m 64 --call-graph dwarf -- sleep 5
|
||||
|
||||
run="collab-$environment-$(date -Iseconds)"
|
||||
echo "Processing data and downloading to '$run.perf'..."
|
||||
|
||||
kubectl -n $environment exec -it deployments/collab -- perf --no-pager script > "$run.perf"
|
||||
|
||||
which inferno-flamegraph 2>/dev/null || (echo "installing inferno..."; cargo install inferno)
|
||||
|
||||
inferno-collapse-perf "$run.perf" | inferno-flamegraph > "$run.svg"
|
||||
open "./$run.svg"
|
||||
251
script/community-pr-track-mapping.json
Normal file
251
script/community-pr-track-mapping.json
Normal file
@@ -0,0 +1,251 @@
|
||||
{
|
||||
"_comment": "Tracks are ordered by specificity (fewest labels first). When a PR has labels matching multiple tracks, the first match wins. To add a new track, insert it at the position that reflects its size. To add a label to an existing track, append it to that track's labels array.",
|
||||
"tracks": [
|
||||
{
|
||||
"name": "Edit Predictions",
|
||||
"labels": ["area:ai/edit prediction", "area:ai/supermaven", "area:ai/zeta"]
|
||||
},
|
||||
{
|
||||
"name": "Tree-sitter",
|
||||
"labels": ["area:tree-sitter"]
|
||||
},
|
||||
{
|
||||
"name": "Design",
|
||||
"labels": ["area:design papercut"]
|
||||
},
|
||||
{
|
||||
"name": "Terminal",
|
||||
"labels": ["area:integrations/terminal"]
|
||||
},
|
||||
{
|
||||
"name": "Extensions",
|
||||
"labels": ["area:extensions/infrastructure"]
|
||||
},
|
||||
{
|
||||
"name": "Tasks & REPL",
|
||||
"labels": ["area:tasks", "area:repl"]
|
||||
},
|
||||
{
|
||||
"name": "Markdown Preview",
|
||||
"labels": ["area:preview/markdown", "area:preview/mermaid"]
|
||||
},
|
||||
{
|
||||
"name": "NixOS",
|
||||
"labels": ["area:tooling/nix", "platform:nixos"]
|
||||
},
|
||||
{
|
||||
"name": "Collab",
|
||||
"labels": ["area:collab", "area:collab/audio", "area:collab/chat"]
|
||||
},
|
||||
{
|
||||
"name": "Vim, Helix & Keybinds",
|
||||
"labels": ["area:controls/keybinds", "area:parity/helix", "area:parity/vim"]
|
||||
},
|
||||
{
|
||||
"name": "Debugger",
|
||||
"labels": [
|
||||
"area:debugger",
|
||||
"area:debugger/dap/CodeLLDB",
|
||||
"area:debugger/dap/debugpy",
|
||||
"area:debugger/dap/gdb",
|
||||
"area:debugger/dap/javascript"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Git",
|
||||
"labels": [
|
||||
"area:integrations/git",
|
||||
"area:integrations/git/blame",
|
||||
"area:integrations/git/git graph",
|
||||
"area:integrations/git/split view"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "GPUI & Input",
|
||||
"labels": ["area:controls/ime", "area:controls/mouse", "area:gpui"]
|
||||
},
|
||||
{
|
||||
"name": "Auth & Zed.dev",
|
||||
"labels": [
|
||||
"area:auth",
|
||||
"area:billing",
|
||||
"area:permissions",
|
||||
"area:zed account",
|
||||
"area:zed.dev",
|
||||
"area:zed.dev/theme builder"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Linux",
|
||||
"labels": ["area:tooling/flatpak", "platform:linux", "platform:linux/wayland", "platform:linux/x11"]
|
||||
},
|
||||
{
|
||||
"name": "Windows, WSL, Remote, dev containers",
|
||||
"labels": ["area:dev containers", "platform:remote", "platform:windows", "platform:windows/wsl"]
|
||||
},
|
||||
{
|
||||
"name": "Workspace & Navigation",
|
||||
"labels": [
|
||||
"area:command palette",
|
||||
"area:file finder",
|
||||
"area:navigation",
|
||||
"area:outline",
|
||||
"area:project panel",
|
||||
"area:workspace"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Editor & Search",
|
||||
"labels": [
|
||||
"area:code folding",
|
||||
"area:editor",
|
||||
"area:editor/brackets",
|
||||
"area:editor/linked edits",
|
||||
"area:multi-buffer",
|
||||
"area:multi-cursor",
|
||||
"area:search",
|
||||
"area:snippets"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "UI & Settings",
|
||||
"labels": [
|
||||
"area:keymap editor",
|
||||
"area:popovers",
|
||||
"area:preview/images",
|
||||
"area:settings",
|
||||
"area:settings/ui",
|
||||
"area:status bar",
|
||||
"area:ui/animations",
|
||||
"area:ui/dock",
|
||||
"area:ui/file icons",
|
||||
"area:ui/font",
|
||||
"area:ui/menus",
|
||||
"area:ui/minimap",
|
||||
"area:ui/panel",
|
||||
"area:ui/scaling",
|
||||
"area:ui/scrolling",
|
||||
"area:ui/tabs",
|
||||
"area:ui/themes"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "AI",
|
||||
"labels": [
|
||||
"area:ai",
|
||||
"area:ai/acp",
|
||||
"area:ai/agent thread",
|
||||
"area:ai/anthropic",
|
||||
"area:ai/assistant",
|
||||
"area:ai/bedrock",
|
||||
"area:ai/codex",
|
||||
"area:ai/copilot",
|
||||
"area:ai/cursor",
|
||||
"area:ai/deepseek",
|
||||
"area:ai/gemini",
|
||||
"area:ai/inline assistant",
|
||||
"area:ai/lmstudio",
|
||||
"area:ai/mcp",
|
||||
"area:ai/mistral",
|
||||
"area:ai/multiagent sidebar",
|
||||
"area:ai/ollama",
|
||||
"area:ai/openai",
|
||||
"area:ai/openai compatible",
|
||||
"area:ai/opencode",
|
||||
"area:ai/openrouter",
|
||||
"area:ai/qwen",
|
||||
"area:ai/text thread"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "macOS",
|
||||
"labels": ["platform:macOS"]
|
||||
},
|
||||
{
|
||||
"name": "Languages & LSP",
|
||||
"labels": [
|
||||
"area:autocompletions",
|
||||
"area:code actions",
|
||||
"area:diagnostics",
|
||||
"area:inlay hints",
|
||||
"area:language server",
|
||||
"area:language server/server failure",
|
||||
"area:languages",
|
||||
"area:languages/astro",
|
||||
"area:languages/bash",
|
||||
"area:languages/c",
|
||||
"area:languages/c#",
|
||||
"area:languages/c++",
|
||||
"area:languages/clojure",
|
||||
"area:languages/css",
|
||||
"area:languages/dart",
|
||||
"area:languages/elixir",
|
||||
"area:languages/elm",
|
||||
"area:languages/f#",
|
||||
"area:languages/gleam",
|
||||
"area:languages/glsl",
|
||||
"area:languages/go",
|
||||
"area:languages/heex",
|
||||
"area:languages/html",
|
||||
"area:languages/java",
|
||||
"area:languages/javascript",
|
||||
"area:languages/json",
|
||||
"area:languages/jsx",
|
||||
"area:languages/julia",
|
||||
"area:languages/lua",
|
||||
"area:languages/markdown",
|
||||
"area:languages/obj-c",
|
||||
"area:languages/ocaml",
|
||||
"area:languages/php",
|
||||
"area:languages/prisma",
|
||||
"area:languages/proto",
|
||||
"area:languages/python",
|
||||
"area:languages/ruby",
|
||||
"area:languages/rust",
|
||||
"area:languages/svelte",
|
||||
"area:languages/swift",
|
||||
"area:languages/tailwind css",
|
||||
"area:languages/toml",
|
||||
"area:languages/tsx",
|
||||
"area:languages/typescript",
|
||||
"area:languages/unsupported",
|
||||
"area:languages/vue",
|
||||
"area:languages/yaml",
|
||||
"area:languages/zig",
|
||||
"area:semantic tokens",
|
||||
"area:tooling/emmet",
|
||||
"area:tooling/eslint",
|
||||
"area:tooling/prettier"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Performance & Catch-all",
|
||||
"labels": [
|
||||
"area:cli",
|
||||
"area:discoverability",
|
||||
"area:installer-updater",
|
||||
"area:internationalization",
|
||||
"area:internationalization/rtl support",
|
||||
"area:legal",
|
||||
"area:logging",
|
||||
"area:network",
|
||||
"area:onboarding",
|
||||
"area:parity/emacs",
|
||||
"area:parity/jetbrains",
|
||||
"area:parity/vscode",
|
||||
"area:performance",
|
||||
"area:performance/memory leak",
|
||||
"area:release notes",
|
||||
"area:security & privacy",
|
||||
"area:security & privacy/workspace trust",
|
||||
"area:serialization",
|
||||
"area:telemetry",
|
||||
"area:tooling",
|
||||
"area:integrations/environment",
|
||||
"area:accessibility",
|
||||
"area:docs",
|
||||
"platform:general"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
19
script/crate-dep-graph
Executable file
19
script/crate-dep-graph
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
if [[ -x cargo-depgraph ]]; then
|
||||
cargo install cargo-depgraph
|
||||
fi
|
||||
|
||||
graph_file=target/crate-graph.html
|
||||
|
||||
cargo depgraph \
|
||||
--workspace-only \
|
||||
--offline \
|
||||
--root=zed,cli,collab \
|
||||
--dedup-transitive-deps \
|
||||
| dot -Tsvg > $graph_file
|
||||
|
||||
echo "open $graph_file"
|
||||
open $graph_file
|
||||
9
script/create-draft-release
Executable file
9
script/create-draft-release
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
preview=""
|
||||
if [[ "$GITHUB_REF_NAME" == *"-pre" ]]; then
|
||||
preview="-p"
|
||||
fi
|
||||
|
||||
gh release view "$GITHUB_REF_NAME" ||\
|
||||
gh release create -t "$GITHUB_REF_NAME" -d "$GITHUB_REF_NAME" -F "$1" $preview
|
||||
1
script/danger/.gitignore
vendored
Normal file
1
script/danger/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
102
script/danger/dangerfile.ts
Normal file
102
script/danger/dangerfile.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { danger, message, warn, fail } from "danger";
|
||||
const { prHygiene } = require("danger-plugin-pr-hygiene");
|
||||
|
||||
prHygiene({
|
||||
prefixPattern: /^([a-z\d\(\)_\s]+):(.*)/g,
|
||||
rules: {
|
||||
// Don't enable this rule just yet, as it can have false positives.
|
||||
useImperativeMood: "off",
|
||||
noConventionalCommits: {
|
||||
bannedTypes: ["feat", "fix", "style", "refactor", "perf", "test", "chore", "build", "revert"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const RELEASE_NOTES_PATTERN = /Release Notes:(\r?\n)+- /gm;
|
||||
const body = danger.github.pr.body;
|
||||
|
||||
const hasReleaseNotes = RELEASE_NOTES_PATTERN.test(body);
|
||||
|
||||
if (!hasReleaseNotes) {
|
||||
warn(
|
||||
[
|
||||
"This PR is missing release notes.",
|
||||
"",
|
||||
'Please add a "Release Notes" section that describes the change:',
|
||||
"",
|
||||
"```",
|
||||
"Release Notes:",
|
||||
"",
|
||||
"- Added/Fixed/Improved ...",
|
||||
"```",
|
||||
"",
|
||||
'If your change is not user-facing, you can use "N/A" for the entry:',
|
||||
"```",
|
||||
"Release Notes:",
|
||||
"",
|
||||
"- N/A",
|
||||
"```",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
const ISSUE_LINK_PATTERN =
|
||||
/(?:- )?(?<!(?:Close[sd]?|Fixe[sd]|Resolve[sd]|Implement[sed]|Follow-up of|Part of):?\s+)https:\/\/github\.com\/[\w-]+\/[\w-]+\/issues\/\d+/gi;
|
||||
|
||||
const bodyWithoutReleaseNotes = hasReleaseNotes ? body.split(/Release Notes:/)[0] : body;
|
||||
const includesIssueUrl = ISSUE_LINK_PATTERN.test(bodyWithoutReleaseNotes);
|
||||
|
||||
if (includesIssueUrl) {
|
||||
const matches = bodyWithoutReleaseNotes.match(ISSUE_LINK_PATTERN) ?? [];
|
||||
const issues = matches
|
||||
.map((match) => match.replace(/^#/, "").replace(/https:\/\/github\.com\/zed-industries\/zed\/issues\//, ""))
|
||||
.filter((issue, index, self) => self.indexOf(issue) === index);
|
||||
|
||||
const issuesToReport = issues.map((issue) => `#${issue}`).join(", ");
|
||||
message(
|
||||
[
|
||||
`This PR includes links to the following GitHub Issues: ${issuesToReport}`,
|
||||
"If this PR aims to close an issue, please include a `Closes #ISSUE` line at the top of the PR body.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
const MIGRATION_SCHEMA_FILES = [
|
||||
"crates/collab/migrations/20251208000000_test_schema.sql",
|
||||
"crates/collab/migrations.sqlite/20221109000000_test_schema.sql",
|
||||
];
|
||||
|
||||
const modifiedSchemaFiles = danger.git.modified_files.filter((file) =>
|
||||
MIGRATION_SCHEMA_FILES.some((schemaFilePath) => file.endsWith(schemaFilePath)),
|
||||
);
|
||||
|
||||
if (modifiedSchemaFiles.length > 0) {
|
||||
warn(
|
||||
[
|
||||
"This PR modifies database schema files.",
|
||||
"",
|
||||
"If you are making database changes, a migration needs to be added in the Cloud repository.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
const FIXTURE_CHANGE_ATTESTATION = "Changes to test fixtures are intentional and necessary.";
|
||||
|
||||
const FIXTURES_PATHS = ["crates/assistant_tools/src/edit_agent/evals/fixtures"];
|
||||
|
||||
const modifiedFixtures = danger.git.modified_files.filter((file) =>
|
||||
FIXTURES_PATHS.some((fixturePath) => file.includes(fixturePath)),
|
||||
);
|
||||
|
||||
if (modifiedFixtures.length > 0) {
|
||||
if (!body.includes(FIXTURE_CHANGE_ATTESTATION)) {
|
||||
const modifiedFixturesStr = modifiedFixtures.map((path) => "`" + path + "`").join(", ");
|
||||
fail(
|
||||
[
|
||||
`This PR modifies eval or test fixtures (${modifiedFixturesStr}), which are typically expected to remain unchanged.`,
|
||||
"If these changes are intentional and required, please add the following attestation to your PR description: ",
|
||||
`"${FIXTURE_CHANGE_ATTESTATION}"`,
|
||||
].join("\n\n"),
|
||||
);
|
||||
}
|
||||
}
|
||||
13
script/danger/package.json
Normal file
13
script/danger/package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "danger",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"danger": "danger"
|
||||
},
|
||||
"devDependencies": {
|
||||
"danger": "13.0.7",
|
||||
"danger-plugin-pr-hygiene": "0.7.1"
|
||||
}
|
||||
}
|
||||
839
script/danger/pnpm-lock.yaml
generated
Normal file
839
script/danger/pnpm-lock.yaml
generated
Normal file
@@ -0,0 +1,839 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
devDependencies:
|
||||
danger:
|
||||
specifier: 13.0.7
|
||||
version: 13.0.7
|
||||
danger-plugin-pr-hygiene:
|
||||
specifier: 0.7.1
|
||||
version: 0.7.1
|
||||
|
||||
packages:
|
||||
|
||||
'@gitbeaker/core@38.12.1':
|
||||
resolution: {integrity: sha512-8XMVcBIdVAAoxn7JtqmZ2Ee8f+AZLcCPmqEmPFOXY2jPS84y/DERISg/+sbhhb18iRy+ZsZhpWgQ/r3CkYNJOQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@gitbeaker/requester-utils@38.12.1':
|
||||
resolution: {integrity: sha512-Rc/DgngS0YPN+AY1s9UnexKSy4Lh0bkQVAq9p7PRbRpXb33SlTeCg8eg/8+A/mrMcHgYmP0XhH8lkizyA5tBUQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@gitbeaker/rest@38.12.1':
|
||||
resolution: {integrity: sha512-9KMSDtJ/sIov+5pcH+CAfiJXSiuYgN0KLKQFg0HHWR2DwcjGYkcbmhoZcWsaOWOqq4kihN1l7wX91UoRxxKKTQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@octokit/auth-token@4.0.0':
|
||||
resolution: {integrity: sha512-tY/msAuJo6ARbK6SPIxZrPBms3xPbfwBrulZe0Wtr/DIY9lje2HeV1uoebShn6mx7SjCHif6EjMvoREj+gZ+SA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@octokit/core@5.2.2':
|
||||
resolution: {integrity: sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@octokit/endpoint@9.0.6':
|
||||
resolution: {integrity: sha512-H1fNTMA57HbkFESSt3Y9+FBICv+0jFceJFPWDePYlR/iMGrwM5ph+Dd4XRQs+8X+PUFURLQgX9ChPfhJ/1uNQw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@octokit/graphql@7.1.1':
|
||||
resolution: {integrity: sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@octokit/openapi-types@24.2.0':
|
||||
resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==}
|
||||
|
||||
'@octokit/plugin-paginate-rest@11.4.4-cjs.2':
|
||||
resolution: {integrity: sha512-2dK6z8fhs8lla5PaOTgqfCGBxgAv/le+EhPs27KklPhm1bKObpu6lXzwfUEQ16ajXzqNrKMujsFyo9K2eaoISw==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
'@octokit/core': '5'
|
||||
|
||||
'@octokit/plugin-request-log@4.0.1':
|
||||
resolution: {integrity: sha512-GihNqNpGHorUrO7Qa9JbAl0dbLnqJVrV8OXe2Zm5/Y4wFkZQDfTreBzVmiRfJVfE4mClXdihHnbpyyO9FSX4HA==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
'@octokit/core': '5'
|
||||
|
||||
'@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1':
|
||||
resolution: {integrity: sha512-VUjIjOOvF2oELQmiFpWA1aOPdawpyaCUqcEBc/UOUnj3Xp6DJGrJ1+bjUIIDzdHjnFNO6q57ODMfdEZnoBkCwQ==}
|
||||
engines: {node: '>= 18'}
|
||||
peerDependencies:
|
||||
'@octokit/core': ^5
|
||||
|
||||
'@octokit/request-error@5.1.1':
|
||||
resolution: {integrity: sha512-v9iyEQJH6ZntoENr9/yXxjuezh4My67CBSu9r6Ve/05Iu5gNgnisNWOsoJHTP6k0Rr0+HQIpnH+kyammu90q/g==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@octokit/request@8.4.1':
|
||||
resolution: {integrity: sha512-qnB2+SY3hkCmBxZsR/MPCybNmbJe4KAlfWErXq+rBKkQJlbjdJeS85VI9r8UqeLYLvnAenU8Q1okM/0MBsAGXw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@octokit/rest@20.1.2':
|
||||
resolution: {integrity: sha512-GmYiltypkHHtihFwPRxlaorG5R9VAHuk/vbszVoRTGXnAsY60wYLkh/E2XiFmdZmqrisw+9FaazS1i5SbdWYgA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@octokit/types@13.10.0':
|
||||
resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==}
|
||||
|
||||
agent-base@7.1.4:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
async-retry@1.2.3:
|
||||
resolution: {integrity: sha512-tfDb02Th6CE6pJUF2gjW5ZVjsgwlucVXOEQMvEX9JgSJMs9gAX+Nz3xRuJBKuUYjTSYORqvDBORdAQ3LU59g7Q==}
|
||||
|
||||
before-after-hook@2.2.3:
|
||||
resolution: {integrity: sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ==}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
call-bound@1.0.4:
|
||||
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
colors@1.4.0:
|
||||
resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==}
|
||||
engines: {node: '>=0.1.90'}
|
||||
|
||||
commander@2.20.3:
|
||||
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
|
||||
|
||||
core-js@3.49.0:
|
||||
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
|
||||
|
||||
danger-plugin-pr-hygiene@0.7.1:
|
||||
resolution: {integrity: sha512-ll070nNaL3OeO2nooYWflPE/CRKLeq8GiH2C68u5zM3gW4gepH89GhVv0sYNNGLx4cYwa1zZ/TuiYYhC49z06Q==}
|
||||
|
||||
danger@13.0.7:
|
||||
resolution: {integrity: sha512-H7Syz9P3np7tgOjTYs1DDogjlknPWYwBIJXUTFIK5iFZOQ0b8irkUz5swOLFUmw7j0aKuybhwkXTcfyHFvRzCQ==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
peerDependencies:
|
||||
supports-color: '*'
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
deprecation@2.3.1:
|
||||
resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
es-define-property@1.0.1:
|
||||
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-errors@1.3.0:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
fast-json-patch@3.1.1:
|
||||
resolution: {integrity: sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==}
|
||||
|
||||
fill-range@7.1.1:
|
||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
get-proto@1.0.1:
|
||||
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
gopd@1.2.0:
|
||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
has-flag@4.0.0:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
has-flag@5.0.1:
|
||||
resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
has-symbols@1.1.0:
|
||||
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hasown@2.0.3:
|
||||
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
hyperlinker@1.0.0:
|
||||
resolution: {integrity: sha512-Ty8UblRWFEcfSuIaajM34LdPXIhbs1ajEX/BBPv24J+enSVaEVY63xQ6lTO9VRYS5LAoghIG0IDJ+p+IPzKUQQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
ini@5.0.0:
|
||||
resolution: {integrity: sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==}
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
|
||||
is-number@7.0.0:
|
||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
|
||||
json5@2.2.3:
|
||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
jsonpointer@5.0.1:
|
||||
resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
jwa@2.0.1:
|
||||
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
lodash.includes@4.3.0:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
|
||||
lodash.isboolean@3.0.3:
|
||||
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||
|
||||
lodash.isinteger@4.0.4:
|
||||
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||
|
||||
lodash.isnumber@3.0.3:
|
||||
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||
|
||||
lodash.isobject@3.0.2:
|
||||
resolution: {integrity: sha512-3/Qptq2vr7WeJbB4KHUSKlq8Pl7ASXi3UG6CMbBm8WRtXi8+GHm7mKaU3urfpSEzWe2wCIChs6/sdocUsTKJiA==}
|
||||
|
||||
lodash.isplainobject@4.0.6:
|
||||
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||
|
||||
lodash.isstring@4.0.1:
|
||||
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||
|
||||
lodash.mapvalues@4.6.0:
|
||||
resolution: {integrity: sha512-JPFqXFeZQ7BfS00H58kClY7SPVeHertPE0lNuCyZ26/XlN8TvakYD7b9bGyNmXbT/D3BbtPAAmq90gPWqLkxlQ==}
|
||||
|
||||
lodash.memoize@4.1.2:
|
||||
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
|
||||
|
||||
lodash.once@4.1.1:
|
||||
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
memfs-or-file-map-to-github-branch@1.3.0:
|
||||
resolution: {integrity: sha512-AzgIEodmt51dgwB3TmihTf1Fh2SmszdZskC6trFHy4v71R5shLmdjJSYI7ocVfFa7C/TE6ncb0OZ9eBg2rmkBQ==}
|
||||
|
||||
micromatch@4.0.8:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
node-cleanup@2.1.2:
|
||||
resolution: {integrity: sha512-qN8v/s2PAJwGUtr1/hYTpNKlD6Y9rc4p8KSmJXyGdYGZsDGKXrGThikLFP9OCHFeLeEpQzPwiAtdIvBLqm//Hw==}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
peerDependencies:
|
||||
encoding: ^0.1.0
|
||||
peerDependenciesMeta:
|
||||
encoding:
|
||||
optional: true
|
||||
|
||||
object-inspect@1.13.4:
|
||||
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
override-require@1.1.1:
|
||||
resolution: {integrity: sha512-eoJ9YWxFcXbrn2U8FKT6RV+/Kj7fiGAB1VvHzbYKt8xM5ZuKZgCGvnHzDxmreEjcBH28ejg5MiOH4iyY1mQnkg==}
|
||||
|
||||
parse-diff@0.7.1:
|
||||
resolution: {integrity: sha512-1j3l8IKcy4yRK2W4o9EYvJLSzpAVwz4DXqCewYyx2vEwk2gcf3DBPqc8Fj4XV3K33OYJ08A8fWwyu/ykD/HUSg==}
|
||||
|
||||
parse-github-url@1.0.4:
|
||||
resolution: {integrity: sha512-CEtCOt55fHmd6DpBc/N7H5NC4vJpcquhzzs9Iw2mRj8bVxo1O5TQI5MXKOMO7+yBOqD+5dKCCRK4Kj1KskZc6Q==}
|
||||
engines: {node: '>= 0.10'}
|
||||
hasBin: true
|
||||
|
||||
parse-link-header@2.0.0:
|
||||
resolution: {integrity: sha512-xjU87V0VyHZybn2RrCX5TIFGxTVZE6zqqZWMPlIKiSKuWh/X5WZdt+w1Ki1nXB+8L/KtL+nZ4iq+sfI6MrhhMw==}
|
||||
|
||||
picomatch@2.3.2:
|
||||
resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
pinpoint@1.1.0:
|
||||
resolution: {integrity: sha512-+04FTD9x7Cls2rihLlo57QDCcHoLBGn5Dk51SwtFBWkUWLxZaBXyNVpCw1S+atvE7GmnFjeaRZ0WLq3UYuqAdg==}
|
||||
|
||||
prettyjson@1.2.5:
|
||||
resolution: {integrity: sha512-rksPWtoZb2ZpT5OVgtmy0KHVM+Dca3iVwWY9ifwhcexfjebtgjg3wmrUt9PvJ59XIYBcknQeYHD8IAnVlh9lAw==}
|
||||
hasBin: true
|
||||
|
||||
qs@6.15.1:
|
||||
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
readline-sync@1.4.10:
|
||||
resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
regenerator-runtime@0.13.11:
|
||||
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
retry@0.12.0:
|
||||
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
semver@7.7.4:
|
||||
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel-weakmap@1.0.2:
|
||||
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
side-channel@1.1.0:
|
||||
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
supports-color@10.2.2:
|
||||
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
supports-hyperlinks@4.4.0:
|
||||
resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
tr46@0.0.3:
|
||||
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||
|
||||
universal-user-agent@6.0.1:
|
||||
resolution: {integrity: sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ==}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
xcase@2.0.1:
|
||||
resolution: {integrity: sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==}
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@gitbeaker/core@38.12.1':
|
||||
dependencies:
|
||||
'@gitbeaker/requester-utils': 38.12.1
|
||||
qs: 6.15.1
|
||||
xcase: 2.0.1
|
||||
|
||||
'@gitbeaker/requester-utils@38.12.1':
|
||||
dependencies:
|
||||
qs: 6.15.1
|
||||
xcase: 2.0.1
|
||||
|
||||
'@gitbeaker/rest@38.12.1':
|
||||
dependencies:
|
||||
'@gitbeaker/core': 38.12.1
|
||||
'@gitbeaker/requester-utils': 38.12.1
|
||||
|
||||
'@octokit/auth-token@4.0.0': {}
|
||||
|
||||
'@octokit/core@5.2.2':
|
||||
dependencies:
|
||||
'@octokit/auth-token': 4.0.0
|
||||
'@octokit/graphql': 7.1.1
|
||||
'@octokit/request': 8.4.1
|
||||
'@octokit/request-error': 5.1.1
|
||||
'@octokit/types': 13.10.0
|
||||
before-after-hook: 2.2.3
|
||||
universal-user-agent: 6.0.1
|
||||
|
||||
'@octokit/endpoint@9.0.6':
|
||||
dependencies:
|
||||
'@octokit/types': 13.10.0
|
||||
universal-user-agent: 6.0.1
|
||||
|
||||
'@octokit/graphql@7.1.1':
|
||||
dependencies:
|
||||
'@octokit/request': 8.4.1
|
||||
'@octokit/types': 13.10.0
|
||||
universal-user-agent: 6.0.1
|
||||
|
||||
'@octokit/openapi-types@24.2.0': {}
|
||||
|
||||
'@octokit/plugin-paginate-rest@11.4.4-cjs.2(@octokit/core@5.2.2)':
|
||||
dependencies:
|
||||
'@octokit/core': 5.2.2
|
||||
'@octokit/types': 13.10.0
|
||||
|
||||
'@octokit/plugin-request-log@4.0.1(@octokit/core@5.2.2)':
|
||||
dependencies:
|
||||
'@octokit/core': 5.2.2
|
||||
|
||||
'@octokit/plugin-rest-endpoint-methods@13.3.2-cjs.1(@octokit/core@5.2.2)':
|
||||
dependencies:
|
||||
'@octokit/core': 5.2.2
|
||||
'@octokit/types': 13.10.0
|
||||
|
||||
'@octokit/request-error@5.1.1':
|
||||
dependencies:
|
||||
'@octokit/types': 13.10.0
|
||||
deprecation: 2.3.1
|
||||
once: 1.4.0
|
||||
|
||||
'@octokit/request@8.4.1':
|
||||
dependencies:
|
||||
'@octokit/endpoint': 9.0.6
|
||||
'@octokit/request-error': 5.1.1
|
||||
'@octokit/types': 13.10.0
|
||||
universal-user-agent: 6.0.1
|
||||
|
||||
'@octokit/rest@20.1.2':
|
||||
dependencies:
|
||||
'@octokit/core': 5.2.2
|
||||
'@octokit/plugin-paginate-rest': 11.4.4-cjs.2(@octokit/core@5.2.2)
|
||||
'@octokit/plugin-request-log': 4.0.1(@octokit/core@5.2.2)
|
||||
'@octokit/plugin-rest-endpoint-methods': 13.3.2-cjs.1(@octokit/core@5.2.2)
|
||||
|
||||
'@octokit/types@13.10.0':
|
||||
dependencies:
|
||||
'@octokit/openapi-types': 24.2.0
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
async-retry@1.2.3:
|
||||
dependencies:
|
||||
retry: 0.12.0
|
||||
|
||||
before-after-hook@2.2.3: {}
|
||||
|
||||
braces@3.0.3:
|
||||
dependencies:
|
||||
fill-range: 7.1.1
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
call-bind-apply-helpers@1.0.2:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
function-bind: 1.1.2
|
||||
|
||||
call-bound@1.0.4:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
get-intrinsic: 1.3.0
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
colors@1.4.0: {}
|
||||
|
||||
commander@2.20.3: {}
|
||||
|
||||
core-js@3.49.0: {}
|
||||
|
||||
danger-plugin-pr-hygiene@0.7.1: {}
|
||||
|
||||
danger@13.0.7:
|
||||
dependencies:
|
||||
'@gitbeaker/rest': 38.12.1
|
||||
'@octokit/rest': 20.1.2
|
||||
async-retry: 1.2.3
|
||||
chalk: 4.1.2
|
||||
commander: 2.20.3
|
||||
core-js: 3.49.0
|
||||
debug: 4.4.3
|
||||
fast-json-patch: 3.1.1
|
||||
http-proxy-agent: 7.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
hyperlinker: 1.0.0
|
||||
ini: 5.0.0
|
||||
json5: 2.2.3
|
||||
jsonpointer: 5.0.1
|
||||
jsonwebtoken: 9.0.3
|
||||
lodash.includes: 4.3.0
|
||||
lodash.isobject: 3.0.2
|
||||
lodash.mapvalues: 4.6.0
|
||||
lodash.memoize: 4.1.2
|
||||
memfs-or-file-map-to-github-branch: 1.3.0
|
||||
micromatch: 4.0.8
|
||||
node-cleanup: 2.1.2
|
||||
node-fetch: 2.7.0
|
||||
override-require: 1.1.1
|
||||
parse-diff: 0.7.1
|
||||
parse-github-url: 1.0.4
|
||||
parse-link-header: 2.0.0
|
||||
pinpoint: 1.1.0
|
||||
prettyjson: 1.2.5
|
||||
readline-sync: 1.4.10
|
||||
regenerator-runtime: 0.13.11
|
||||
require-from-string: 2.0.2
|
||||
supports-hyperlinks: 4.4.0
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
- supports-color
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
deprecation@2.3.1: {}
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
es-define-property@1.0.1: {}
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
fast-json-patch@3.1.1: {}
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
to-regex-range: 5.0.1
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
es-define-property: 1.0.1
|
||||
es-errors: 1.3.0
|
||||
es-object-atoms: 1.1.1
|
||||
function-bind: 1.1.2
|
||||
get-proto: 1.0.1
|
||||
gopd: 1.2.0
|
||||
has-symbols: 1.1.0
|
||||
hasown: 2.0.3
|
||||
math-intrinsics: 1.1.0
|
||||
|
||||
get-proto@1.0.1:
|
||||
dependencies:
|
||||
dunder-proto: 1.0.1
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
gopd@1.2.0: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
has-flag@5.0.1: {}
|
||||
|
||||
has-symbols@1.1.0: {}
|
||||
|
||||
hasown@2.0.3:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
http-proxy-agent@7.0.2:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
hyperlinker@1.0.0: {}
|
||||
|
||||
ini@5.0.0: {}
|
||||
|
||||
is-number@7.0.0: {}
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
jsonpointer@5.0.1: {}
|
||||
|
||||
jsonwebtoken@9.0.3:
|
||||
dependencies:
|
||||
jws: 4.0.1
|
||||
lodash.includes: 4.3.0
|
||||
lodash.isboolean: 3.0.3
|
||||
lodash.isinteger: 4.0.4
|
||||
lodash.isnumber: 3.0.3
|
||||
lodash.isplainobject: 4.0.6
|
||||
lodash.isstring: 4.0.1
|
||||
lodash.once: 4.1.1
|
||||
ms: 2.1.3
|
||||
semver: 7.7.4
|
||||
|
||||
jwa@2.0.1:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jws@4.0.1:
|
||||
dependencies:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
lodash.includes@4.3.0: {}
|
||||
|
||||
lodash.isboolean@3.0.3: {}
|
||||
|
||||
lodash.isinteger@4.0.4: {}
|
||||
|
||||
lodash.isnumber@3.0.3: {}
|
||||
|
||||
lodash.isobject@3.0.2: {}
|
||||
|
||||
lodash.isplainobject@4.0.6: {}
|
||||
|
||||
lodash.isstring@4.0.1: {}
|
||||
|
||||
lodash.mapvalues@4.6.0: {}
|
||||
|
||||
lodash.memoize@4.1.2: {}
|
||||
|
||||
lodash.once@4.1.1: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
memfs-or-file-map-to-github-branch@1.3.0:
|
||||
dependencies:
|
||||
'@octokit/rest': 20.1.2
|
||||
|
||||
micromatch@4.0.8:
|
||||
dependencies:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.2
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
node-cleanup@2.1.2: {}
|
||||
|
||||
node-fetch@2.7.0:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
|
||||
object-inspect@1.13.4: {}
|
||||
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
override-require@1.1.1: {}
|
||||
|
||||
parse-diff@0.7.1: {}
|
||||
|
||||
parse-github-url@1.0.4: {}
|
||||
|
||||
parse-link-header@2.0.0:
|
||||
dependencies:
|
||||
xtend: 4.0.2
|
||||
|
||||
picomatch@2.3.2: {}
|
||||
|
||||
pinpoint@1.1.0: {}
|
||||
|
||||
prettyjson@1.2.5:
|
||||
dependencies:
|
||||
colors: 1.4.0
|
||||
minimist: 1.2.8
|
||||
|
||||
qs@6.15.1:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
readline-sync@1.4.10: {}
|
||||
|
||||
regenerator-runtime@0.13.11: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
retry@0.12.0: {}
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
semver@7.7.4: {}
|
||||
|
||||
side-channel-list@1.0.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-map@1.0.1:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
|
||||
side-channel-weakmap@1.0.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
es-errors: 1.3.0
|
||||
get-intrinsic: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-map: 1.0.1
|
||||
|
||||
side-channel@1.1.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
object-inspect: 1.13.4
|
||||
side-channel-list: 1.0.1
|
||||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
supports-color@10.2.2: {}
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
supports-hyperlinks@4.4.0:
|
||||
dependencies:
|
||||
has-flag: 5.0.1
|
||||
supports-color: 10.2.2
|
||||
|
||||
to-regex-range@5.0.1:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
tr46@0.0.3: {}
|
||||
|
||||
universal-user-agent@6.0.1: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
whatwg-url@5.0.0:
|
||||
dependencies:
|
||||
tr46: 0.0.3
|
||||
webidl-conversions: 3.0.1
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
xcase@2.0.1: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
3
script/debug-cli
Executable file
3
script/debug-cli
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
cargo build -p zed && cargo run -p cli -- --foreground --zed=${CARGO_TARGET_DIR:-target}/debug/zed "$@"
|
||||
21
script/deploy-collab
Executable file
21
script/deploy-collab
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eu
|
||||
source script/lib/deploy-helpers.sh
|
||||
|
||||
if [[ $# != 1 ]]; then
|
||||
echo "Usage: $0 <production|staging>"
|
||||
exit 1
|
||||
fi
|
||||
environment=$1
|
||||
tag="$(tag_for_environment $environment)"
|
||||
|
||||
branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
if [ "$branch" != "main" ]; then
|
||||
echo "You must be on main to run this script"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git pull --ff-only origin main
|
||||
git tag -f $tag
|
||||
git push -f origin $tag
|
||||
33
script/determine-release-channel
Executable file
33
script/determine-release-channel
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${GITHUB_ACTIONS-}" ]; then
|
||||
echo "Error: This script must be run in a GitHub Actions environment"
|
||||
exit 1
|
||||
elif [ -z "${GITHUB_REF-}" ]; then
|
||||
# This should be the release tag 'v0.x.x'
|
||||
echo "Error: GITHUB_REF is not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version=$(script/get-crate-version zed)
|
||||
channel=$(cat crates/zed/RELEASE_CHANNEL)
|
||||
echo "Publishing version: ${version} on release channel ${channel}"
|
||||
echo "RELEASE_CHANNEL=${channel}" >> $GITHUB_ENV
|
||||
echo "RELEASE_VERSION=${version}" >> $GITHUB_ENV
|
||||
|
||||
expected_tag_name=""
|
||||
case ${channel} in
|
||||
stable)
|
||||
expected_tag_name="v${version}";;
|
||||
preview)
|
||||
expected_tag_name="v${version}-pre";;
|
||||
*)
|
||||
echo "can't publish a release on channel ${channel}"
|
||||
exit 1;;
|
||||
esac
|
||||
if [[ $GITHUB_REF_NAME != $expected_tag_name ]]; then
|
||||
echo "invalid release tag ${GITHUB_REF_NAME}. expected ${expected_tag_name}"
|
||||
exit 1
|
||||
fi
|
||||
37
script/determine-release-channel.ps1
Normal file
37
script/determine-release-channel.ps1
Normal file
@@ -0,0 +1,37 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not $env:GITHUB_ACTIONS) {
|
||||
Write-Error "Error: This script must be run in a GitHub Actions environment"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if (-not $env:GITHUB_REF) {
|
||||
Write-Error "Error: GITHUB_REF is not set"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$version = & "script/get-crate-version.ps1" "zed"
|
||||
$channel = Get-Content "crates/zed/RELEASE_CHANNEL"
|
||||
|
||||
Write-Host "Publishing version: $version on release channel $channel"
|
||||
Write-Output "RELEASE_CHANNEL=$channel" >> $env:GITHUB_ENV
|
||||
Write-Output "RELEASE_VERSION=$version" >> $env:GITHUB_ENV
|
||||
|
||||
$expectedTagName = ""
|
||||
switch ($channel) {
|
||||
"stable" {
|
||||
$expectedTagName = "v$version"
|
||||
}
|
||||
"preview" {
|
||||
$expectedTagName = "v$version-pre"
|
||||
}
|
||||
default {
|
||||
Write-Error "can't publish a release on channel $channel"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if ($env:GITHUB_REF_NAME -ne $expectedTagName) {
|
||||
Write-Error "invalid release tag $($env:GITHUB_REF_NAME). expected $expectedTagName"
|
||||
exit 1
|
||||
}
|
||||
38
script/digital-ocean-db.sh
Executable file
38
script/digital-ocean-db.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Check if database name is provided
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <database-name>"
|
||||
doctl databases list
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DATABASE_NAME="$1"
|
||||
DATABASE_ID=$(doctl databases list --format ID,Name --no-header | grep "$DATABASE_NAME" | awk '{print $1}')
|
||||
|
||||
if [ -z "$DATABASE_ID" ]; then
|
||||
echo "Error: Database '$DATABASE_NAME' not found"
|
||||
exit 1
|
||||
fi
|
||||
CURRENT_IP=$(curl -s https://api.ipify.org)
|
||||
if [ -z "$CURRENT_IP" ]; then
|
||||
echo "Error: Failed to get current IP address"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXISTING_RULE=$(doctl databases firewalls list "$DATABASE_ID" | grep "ip_addr" | grep "$CURRENT_IP")
|
||||
|
||||
if [ -z "$EXISTING_RULE" ]; then
|
||||
echo "IP not found in whitelist. Adding $CURRENT_IP to database firewall..."
|
||||
doctl databases firewalls append "$DATABASE_ID" --rule ip_addr:"$CURRENT_IP"
|
||||
fi
|
||||
|
||||
CONNECTION_URL=$(doctl databases connection "$DATABASE_ID" --format URI --no-header)
|
||||
|
||||
if [ -z "$CONNECTION_URL" ]; then
|
||||
echo "Error: Failed to get database connection details"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
psql "$CONNECTION_URL"
|
||||
231
script/docs-strip-preview-callouts
Executable file
231
script/docs-strip-preview-callouts
Executable file
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Remove Preview callouts from documentation for stable release.
|
||||
#
|
||||
# Usage:
|
||||
# script/docs-strip-preview-callouts [--dry-run]
|
||||
#
|
||||
# This script finds and removes all Preview-related callouts from docs:
|
||||
# > **Preview:** This feature is available in Zed Preview...
|
||||
# > **Changed in Preview (v0.XXX).** See [release notes]...
|
||||
#
|
||||
# Then creates a PR with the changes.
|
||||
#
|
||||
# Options:
|
||||
# --dry-run Show what would be changed without modifying files or creating PR
|
||||
# --verbose Show detailed progress
|
||||
#
|
||||
# Run this as part of the stable release workflow.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DRY_RUN=false
|
||||
VERBOSE=false
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
if [[ "$VERBOSE" == "true" ]]; then
|
||||
echo -e "${BLUE}[strip-preview]${NC} $*" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}Error:${NC} $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
head -18 "$0" | tail -16
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
error "Unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Get repo root
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
DOCS_DIR="$REPO_ROOT/docs/src"
|
||||
|
||||
echo "Searching for Preview callouts in $DOCS_DIR..."
|
||||
|
||||
# Find files with either type of preview callout:
|
||||
# - > **Preview:** ...
|
||||
# - > **Changed in Preview ...
|
||||
files_with_callouts=$(grep -rlE "> \*\*(Preview:|Changed in Preview)" "$DOCS_DIR" 2>/dev/null || true)
|
||||
|
||||
if [[ -z "$files_with_callouts" ]]; then
|
||||
echo "No Preview callouts found. Nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
file_count=$(echo "$files_with_callouts" | wc -l | tr -d ' ')
|
||||
echo "Found $file_count file(s) with Preview callouts:"
|
||||
echo ""
|
||||
|
||||
for file in $files_with_callouts; do
|
||||
relative_path="${file#$REPO_ROOT/}"
|
||||
echo " $relative_path"
|
||||
|
||||
if [[ "$VERBOSE" == "true" ]]; then
|
||||
grep -nE "> \*\*(Preview:|Changed in Preview)" "$file" | while read -r line; do
|
||||
echo " $line"
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo -e "${YELLOW}=== DRY RUN ===${NC}"
|
||||
echo ""
|
||||
echo "Would remove Preview callouts from the files above and create a PR."
|
||||
echo ""
|
||||
echo "Lines to be removed:"
|
||||
echo ""
|
||||
|
||||
for file in $files_with_callouts; do
|
||||
relative_path="${file#$REPO_ROOT/}"
|
||||
echo "--- $relative_path ---"
|
||||
grep -nE "> \*\*(Preview:|Changed in Preview)" "$file" || true
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo -e "${YELLOW}=== END DRY RUN ===${NC}"
|
||||
echo ""
|
||||
echo "Run without --dry-run to apply changes and create PR."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check for clean working state (ignore untracked files)
|
||||
if [[ -n "$(git status --porcelain docs/ | grep -v '^??' || true)" ]]; then
|
||||
error "docs/ directory has uncommitted changes. Please commit or stash first."
|
||||
fi
|
||||
|
||||
# Apply changes
|
||||
echo "Removing Preview callouts..."
|
||||
|
||||
for file in $files_with_callouts; do
|
||||
log "Processing: $file"
|
||||
|
||||
tmp_file=$(mktemp)
|
||||
|
||||
# Remove preview callout lines and their continuations
|
||||
# Handles both:
|
||||
# > **Preview:** This feature is available...
|
||||
# > **Changed in Preview (v0.XXX).** See [release notes]...
|
||||
awk '
|
||||
BEGIN { in_callout = 0 }
|
||||
/^> \*\*Preview:\*\*/ {
|
||||
in_callout = 1
|
||||
next
|
||||
}
|
||||
/^> \*\*Changed in Preview/ {
|
||||
in_callout = 1
|
||||
next
|
||||
}
|
||||
in_callout && /^>/ && !/^> \*\*/ {
|
||||
next
|
||||
}
|
||||
in_callout && /^$/ {
|
||||
in_callout = 0
|
||||
next
|
||||
}
|
||||
{
|
||||
in_callout = 0
|
||||
print
|
||||
}
|
||||
' "$file" > "$tmp_file"
|
||||
|
||||
mv "$tmp_file" "$file"
|
||||
echo " Updated: ${file#$REPO_ROOT/}"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Preview callouts removed from $file_count file(s).${NC}"
|
||||
|
||||
# Check if there are actual changes (in case callouts were in comments or something)
|
||||
if [[ -z "$(git status --porcelain docs/)" ]]; then
|
||||
echo ""
|
||||
echo "No effective changes to commit (callouts may have been in non-rendered content)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Create branch and PR
|
||||
echo ""
|
||||
echo "Creating PR..."
|
||||
|
||||
BRANCH_NAME="docs/stable-release-$(date +%Y-%m-%d)"
|
||||
log "Branch: $BRANCH_NAME"
|
||||
|
||||
git checkout -b "$BRANCH_NAME"
|
||||
git add docs/src/
|
||||
|
||||
# Build file list for commit message
|
||||
FILE_LIST=$(echo "$files_with_callouts" | sed "s|$REPO_ROOT/||" | sed 's/^/- /')
|
||||
|
||||
git commit -m "docs: Remove Preview callouts for stable release
|
||||
|
||||
Features documented with Preview callouts are now in Stable.
|
||||
|
||||
Files updated:
|
||||
$FILE_LIST"
|
||||
|
||||
git push -u origin "$BRANCH_NAME"
|
||||
|
||||
gh pr create \
|
||||
--title "docs: Remove Preview callouts for stable release" \
|
||||
--body "This PR removes Preview callouts from documentation for features that are now in Stable.
|
||||
|
||||
## Files Updated
|
||||
|
||||
$(echo "$files_with_callouts" | sed "s|$REPO_ROOT/|• |")
|
||||
|
||||
## What This Does
|
||||
|
||||
Removes callouts like:
|
||||
\`\`\`markdown
|
||||
> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release.
|
||||
\`\`\`
|
||||
|
||||
And:
|
||||
\`\`\`markdown
|
||||
> **Changed in Preview (v0.XXX).** See [release notes](/releases#0.XXX).
|
||||
\`\`\`
|
||||
|
||||
These features are now in Stable, so the callouts are no longer needed.
|
||||
|
||||
Release Notes:
|
||||
|
||||
- N/A"
|
||||
|
||||
PR_URL=$(gh pr view --json url --jq '.url')
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Done!${NC}"
|
||||
echo ""
|
||||
echo "PR created: $PR_URL"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Review the PR to ensure callouts were removed correctly"
|
||||
echo "2. Merge the PR as part of the stable release"
|
||||
620
script/docs-suggest
Executable file
620
script/docs-suggest
Executable file
@@ -0,0 +1,620 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Analyze code changes and suggest documentation updates.
|
||||
#
|
||||
# Usage:
|
||||
# script/docs-suggest --pr 49177 # Analyze a PR (immediate mode)
|
||||
# script/docs-suggest --commit abc123 # Analyze a commit
|
||||
# script/docs-suggest --diff file.patch # Analyze a diff file
|
||||
# script/docs-suggest --staged # Analyze staged changes
|
||||
#
|
||||
# Modes:
|
||||
# --batch Append suggestions to batch file for later PR (default for main)
|
||||
# --immediate Output suggestions directly (default for cherry-picks)
|
||||
#
|
||||
# Options:
|
||||
# --dry-run Show assembled context without calling LLM
|
||||
# --output FILE Write suggestions to file instead of stdout (immediate mode)
|
||||
# --verbose Show detailed progress
|
||||
# --model NAME Override default model
|
||||
# --preview Add preview callout to suggested docs (auto-detected)
|
||||
#
|
||||
# Batch mode:
|
||||
# Suggestions are appended to docs/.suggestions/pending.md
|
||||
# Use script/docs-suggest-publish to create a PR from batched suggestions
|
||||
#
|
||||
# Examples:
|
||||
# # Analyze a PR to main (batches by default)
|
||||
# script/docs-suggest --pr 49100
|
||||
#
|
||||
# # Analyze a cherry-pick PR (immediate by default)
|
||||
# script/docs-suggest --pr 49152
|
||||
#
|
||||
# # Force immediate output for testing
|
||||
# script/docs-suggest --pr 49100 --immediate
|
||||
#
|
||||
# # Dry run to see context
|
||||
# script/docs-suggest --pr 49100 --dry-run
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Defaults
|
||||
MODE=""
|
||||
TARGET=""
|
||||
DRY_RUN=false
|
||||
VERBOSE=false
|
||||
OUTPUT=""
|
||||
MODEL="${DROID_MODEL:-claude-sonnet-4-5-20250929}"
|
||||
OUTPUT_MODE="" # batch or immediate, auto-detected if not set
|
||||
ADD_PREVIEW_CALLOUT="" # auto-detected if not set
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log() {
|
||||
if [[ "$VERBOSE" == "true" ]]; then
|
||||
echo -e "${BLUE}[docs-suggest]${NC} $*" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}Error:${NC} $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
warn() {
|
||||
echo -e "${YELLOW}Warning:${NC} $*" >&2
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--pr)
|
||||
MODE="pr"
|
||||
TARGET="$2"
|
||||
shift 2
|
||||
;;
|
||||
--commit)
|
||||
MODE="commit"
|
||||
TARGET="$2"
|
||||
shift 2
|
||||
;;
|
||||
--diff)
|
||||
MODE="diff"
|
||||
TARGET="$2"
|
||||
shift 2
|
||||
;;
|
||||
--staged)
|
||||
MODE="staged"
|
||||
shift
|
||||
;;
|
||||
--batch)
|
||||
OUTPUT_MODE="batch"
|
||||
shift
|
||||
;;
|
||||
--immediate)
|
||||
OUTPUT_MODE="immediate"
|
||||
shift
|
||||
;;
|
||||
--preview)
|
||||
ADD_PREVIEW_CALLOUT="true"
|
||||
shift
|
||||
;;
|
||||
--no-preview)
|
||||
ADD_PREVIEW_CALLOUT="false"
|
||||
shift
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=true
|
||||
shift
|
||||
;;
|
||||
--output)
|
||||
OUTPUT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--model)
|
||||
MODEL="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
head -42 "$0" | tail -40
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
error "Unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate mode
|
||||
if [[ -z "$MODE" ]]; then
|
||||
error "Must specify one of: --pr, --commit, --diff, or --staged"
|
||||
fi
|
||||
|
||||
# Get repo root
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Batch file location
|
||||
BATCH_DIR="$REPO_ROOT/docs/.suggestions"
|
||||
BATCH_FILE="$BATCH_DIR/pending.md"
|
||||
|
||||
# Temp directory for context assembly
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
log "Mode: $MODE, Target: ${TARGET:-staged changes}"
|
||||
log "Temp dir: $TMPDIR"
|
||||
|
||||
# ============================================================================
|
||||
# Step 1: Get the diff and detect context
|
||||
# ============================================================================
|
||||
|
||||
get_diff() {
|
||||
case $MODE in
|
||||
pr)
|
||||
if ! command -v gh &> /dev/null; then
|
||||
error "gh CLI required for --pr mode. Install: https://cli.github.com"
|
||||
fi
|
||||
log "Fetching PR #$TARGET info..."
|
||||
|
||||
# Get PR metadata for auto-detection
|
||||
PR_JSON=$(gh pr view "$TARGET" --json baseRefName,title,number)
|
||||
PR_BASE=$(echo "$PR_JSON" | grep -o '"baseRefName":"[^"]*"' | cut -d'"' -f4)
|
||||
PR_TITLE=$(echo "$PR_JSON" | grep -o '"title":"[^"]*"' | cut -d'"' -f4)
|
||||
PR_NUMBER=$(echo "$PR_JSON" | grep -o '"number":[0-9]*' | cut -d':' -f2)
|
||||
|
||||
log "PR #$PR_NUMBER: $PR_TITLE (base: $PR_BASE)"
|
||||
|
||||
# Auto-detect output mode based on target branch
|
||||
if [[ -z "$OUTPUT_MODE" ]]; then
|
||||
if [[ "$PR_BASE" == "main" ]]; then
|
||||
OUTPUT_MODE="batch"
|
||||
log "Auto-detected: batch mode (PR targets main)"
|
||||
else
|
||||
OUTPUT_MODE="immediate"
|
||||
log "Auto-detected: immediate mode (PR targets $PR_BASE)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Auto-detect preview callout
|
||||
if [[ -z "$ADD_PREVIEW_CALLOUT" ]]; then
|
||||
if [[ "$PR_BASE" == "main" ]]; then
|
||||
ADD_PREVIEW_CALLOUT="true"
|
||||
log "Auto-detected: will add preview callout (new feature going to main)"
|
||||
else
|
||||
# Cherry-pick to release branch - check if it's preview or stable
|
||||
ADD_PREVIEW_CALLOUT="false"
|
||||
log "Auto-detected: no preview callout (cherry-pick)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Store metadata for batch mode
|
||||
echo "$PR_NUMBER" > "$TMPDIR/pr_number"
|
||||
echo "$PR_TITLE" > "$TMPDIR/pr_title"
|
||||
echo "$PR_BASE" > "$TMPDIR/pr_base"
|
||||
|
||||
log "Fetching PR #$TARGET diff..."
|
||||
gh pr diff "$TARGET" > "$TMPDIR/changes.diff"
|
||||
gh pr diff "$TARGET" --name-only > "$TMPDIR/changed_files.txt"
|
||||
;;
|
||||
commit)
|
||||
log "Getting commit $TARGET diff..."
|
||||
git show "$TARGET" --format="" > "$TMPDIR/changes.diff"
|
||||
git show "$TARGET" --format="" --name-only > "$TMPDIR/changed_files.txt"
|
||||
|
||||
# Default to immediate for commits
|
||||
OUTPUT_MODE="${OUTPUT_MODE:-immediate}"
|
||||
ADD_PREVIEW_CALLOUT="${ADD_PREVIEW_CALLOUT:-false}"
|
||||
;;
|
||||
diff)
|
||||
if [[ ! -f "$TARGET" ]]; then
|
||||
error "Diff file not found: $TARGET"
|
||||
fi
|
||||
log "Using provided diff file..."
|
||||
cp "$TARGET" "$TMPDIR/changes.diff"
|
||||
grep -E '^\+\+\+ b/' "$TARGET" | sed 's|^+++ b/||' > "$TMPDIR/changed_files.txt" || true
|
||||
|
||||
OUTPUT_MODE="${OUTPUT_MODE:-immediate}"
|
||||
ADD_PREVIEW_CALLOUT="${ADD_PREVIEW_CALLOUT:-false}"
|
||||
;;
|
||||
staged)
|
||||
log "Getting staged changes..."
|
||||
git diff --cached > "$TMPDIR/changes.diff"
|
||||
git diff --cached --name-only > "$TMPDIR/changed_files.txt"
|
||||
|
||||
OUTPUT_MODE="${OUTPUT_MODE:-immediate}"
|
||||
ADD_PREVIEW_CALLOUT="${ADD_PREVIEW_CALLOUT:-false}"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ ! -s "$TMPDIR/changes.diff" ]]; then
|
||||
error "No changes found"
|
||||
fi
|
||||
|
||||
log "Found $(wc -l < "$TMPDIR/changed_files.txt" | tr -d ' ') changed files"
|
||||
log "Output mode: $OUTPUT_MODE, Preview callout: $ADD_PREVIEW_CALLOUT"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Step 2: Filter to relevant changes
|
||||
# ============================================================================
|
||||
|
||||
filter_changes() {
|
||||
log "Filtering to documentation-relevant changes..."
|
||||
|
||||
# Keep only source code changes (not tests, not CI, not docs themselves)
|
||||
grep -E '^crates/.*\.rs$' "$TMPDIR/changed_files.txt" | \
|
||||
grep -v '_test\.rs$' | \
|
||||
grep -v '/tests/' | \
|
||||
grep -v '/test_' > "$TMPDIR/source_files.txt" || true
|
||||
|
||||
# Also track if settings/keybindings changed
|
||||
grep -E '(settings|keymap|actions)' "$TMPDIR/changed_files.txt" > "$TMPDIR/config_files.txt" || true
|
||||
|
||||
local source_count=$(wc -l < "$TMPDIR/source_files.txt" | tr -d ' ')
|
||||
local config_count=$(wc -l < "$TMPDIR/config_files.txt" | tr -d ' ')
|
||||
|
||||
log "Relevant files: $source_count source, $config_count config"
|
||||
|
||||
if [[ "$source_count" -eq 0 && "$config_count" -eq 0 ]]; then
|
||||
echo "No documentation-relevant changes detected (only tests, CI, or docs modified)."
|
||||
exit 0
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Step 3: Assemble context
|
||||
# ============================================================================
|
||||
|
||||
assemble_context() {
|
||||
log "Assembling context..."
|
||||
|
||||
# Start the prompt
|
||||
cat > "$TMPDIR/prompt.md" << 'PROMPT_HEADER'
|
||||
# Documentation Suggestion Request
|
||||
|
||||
You are analyzing code changes to determine if documentation updates are needed.
|
||||
|
||||
Before analysis, read and follow these rule files:
|
||||
- `.rules`
|
||||
- `docs/.rules`
|
||||
|
||||
## Your Task
|
||||
|
||||
1. Analyze the diff below for user-facing changes
|
||||
2. Determine if any documentation updates are warranted
|
||||
3. If yes, provide specific, actionable suggestions
|
||||
4. If no, explain why no updates are needed
|
||||
|
||||
## Guidelines
|
||||
|
||||
PROMPT_HEADER
|
||||
|
||||
# Add conventions
|
||||
log "Adding documentation conventions..."
|
||||
cat >> "$TMPDIR/prompt.md" << 'CONVENTIONS'
|
||||
### Documentation Conventions
|
||||
|
||||
- **Voice**: Second person ("you"), present tense, direct and concise
|
||||
- **No hedging**: Avoid "simply", "just", "easily"
|
||||
- **Settings pattern**: Show Settings Editor UI first, then JSON as alternative
|
||||
- **Keybindings**: Use `{#kb action::Name}` syntax, not hardcoded keys
|
||||
- **Terminology**: "folder" not "directory", "project" not "workspace", "Settings Editor" not "settings UI"
|
||||
- **SEO keyword targeting**: For each docs page you suggest updating, choose one
|
||||
primary keyword/intent phrase using the page's user intent
|
||||
- **SEO metadata**: Every updated/new docs page should include frontmatter with
|
||||
`title` and `description` (single-line `key: value` entries)
|
||||
- **Metadata quality**: Titles should clearly state page intent (~50-60 chars),
|
||||
descriptions should summarize the reader outcome (~140-160 chars)
|
||||
- **Keyword usage**: Use the primary keyword naturally in frontmatter and in page
|
||||
body at least once; never keyword-stuff
|
||||
- **SEO structure**: Keep exactly one H1 and preserve logical H1→H2→H3
|
||||
hierarchy
|
||||
- **Internal links minimum**: Non-reference pages should include at least 3
|
||||
useful internal docs links; reference pages can include fewer when extra links
|
||||
would be noise
|
||||
- **Marketing links**: For main feature pages, include a relevant `zed.dev`
|
||||
marketing link alongside docs links
|
||||
|
||||
### Brand Voice Rubric (Required)
|
||||
|
||||
For suggested doc text, apply the brand rubric scoring exactly and only pass text
|
||||
that scores 4+ on every criterion:
|
||||
|
||||
| Criterion |
|
||||
| -------------------- |
|
||||
| Technical Grounding |
|
||||
| Natural Syntax |
|
||||
| Quiet Confidence |
|
||||
| Developer Respect |
|
||||
| Information Priority |
|
||||
| Specificity |
|
||||
| Voice Consistency |
|
||||
| Earned Claims |
|
||||
|
||||
Pass threshold: all criteria 4+ (minimum 32/40 total).
|
||||
|
||||
Also reject suggestions containing obvious taboo phrasing (hype, emotional
|
||||
manipulation, or marketing-style superlatives).
|
||||
|
||||
For every docs file you suggest changing, treat the entire file as in scope for
|
||||
brand review (not only the edited section). Include any additional full-file
|
||||
voice fixes needed to reach passing rubric scores.
|
||||
|
||||
### What Requires Documentation
|
||||
|
||||
- New user-facing features or commands
|
||||
- Changed keybindings or default behaviors
|
||||
- New or modified settings
|
||||
- Deprecated or removed functionality
|
||||
|
||||
### What Does NOT Require Documentation
|
||||
|
||||
- Internal refactoring without behavioral changes
|
||||
- Performance optimizations (unless user-visible)
|
||||
- Bug fixes that restore documented behavior
|
||||
- Test changes, CI changes
|
||||
CONVENTIONS
|
||||
|
||||
# Add preview callout instructions if needed
|
||||
if [[ "$ADD_PREVIEW_CALLOUT" == "true" ]]; then
|
||||
# Get current preview version for modification callouts
|
||||
local preview_version
|
||||
preview_version=$(gh release list --limit 5 2>/dev/null | grep -E '\-pre\s' | head -1 | grep -oE 'v[0-9]+\.[0-9]+' | head -1 || echo "v0.XXX")
|
||||
preview_version="${preview_version#v}" # Remove leading 'v'
|
||||
|
||||
cat >> "$TMPDIR/prompt.md" << PREVIEW_INSTRUCTIONS
|
||||
|
||||
### Preview Release Callouts
|
||||
|
||||
This change is going into Zed Preview first. Use the appropriate callout based on the type of change:
|
||||
|
||||
#### For NEW/ADDITIVE features (new commands, new settings, new UI elements):
|
||||
|
||||
\`\`\`markdown
|
||||
> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release.
|
||||
\`\`\`
|
||||
|
||||
#### For BEHAVIOR MODIFICATIONS (changed defaults, altered behavior of existing features):
|
||||
|
||||
\`\`\`markdown
|
||||
> **Changed in Preview (v${preview_version}).** See [release notes](/releases#${preview_version}).
|
||||
\`\`\`
|
||||
|
||||
**Guidelines:**
|
||||
- Use the "Preview" callout for entirely new features or sections
|
||||
- Use the "Changed in Preview" callout when modifying documentation of existing behavior
|
||||
- Place callouts immediately after the section heading, before any content
|
||||
- Both callout types will be stripped when the feature ships to Stable
|
||||
PREVIEW_INSTRUCTIONS
|
||||
fi
|
||||
|
||||
echo "" >> "$TMPDIR/prompt.md"
|
||||
echo "## Changed Files" >> "$TMPDIR/prompt.md"
|
||||
echo "" >> "$TMPDIR/prompt.md"
|
||||
echo '```' >> "$TMPDIR/prompt.md"
|
||||
cat "$TMPDIR/changed_files.txt" >> "$TMPDIR/prompt.md"
|
||||
echo '```' >> "$TMPDIR/prompt.md"
|
||||
echo "" >> "$TMPDIR/prompt.md"
|
||||
|
||||
# Add the diff (truncated if huge)
|
||||
echo "## Code Diff" >> "$TMPDIR/prompt.md"
|
||||
echo "" >> "$TMPDIR/prompt.md"
|
||||
|
||||
local diff_lines=$(wc -l < "$TMPDIR/changes.diff" | tr -d ' ')
|
||||
if [[ "$diff_lines" -gt 2000 ]]; then
|
||||
warn "Diff is large ($diff_lines lines), truncating to 2000 lines"
|
||||
echo '```diff' >> "$TMPDIR/prompt.md"
|
||||
head -2000 "$TMPDIR/changes.diff" >> "$TMPDIR/prompt.md"
|
||||
echo "" >> "$TMPDIR/prompt.md"
|
||||
echo "[... truncated, $((diff_lines - 2000)) more lines ...]" >> "$TMPDIR/prompt.md"
|
||||
echo '```' >> "$TMPDIR/prompt.md"
|
||||
else
|
||||
echo '```diff' >> "$TMPDIR/prompt.md"
|
||||
cat "$TMPDIR/changes.diff" >> "$TMPDIR/prompt.md"
|
||||
echo '```' >> "$TMPDIR/prompt.md"
|
||||
fi
|
||||
|
||||
# Add output format instructions
|
||||
cat >> "$TMPDIR/prompt.md" << 'OUTPUT_FORMAT'
|
||||
|
||||
## Output Format
|
||||
|
||||
Respond with ONE of these formats:
|
||||
|
||||
### If documentation updates ARE needed:
|
||||
|
||||
```markdown
|
||||
## Documentation Suggestions
|
||||
|
||||
### Summary
|
||||
[1-2 sentence summary of what changed and why docs need updating]
|
||||
|
||||
### Suggested Changes
|
||||
|
||||
#### 1. [docs/src/path/to/file.md]
|
||||
- **Section**: [existing section to update, or "New section"]
|
||||
- **Change**: [Add/Update/Remove]
|
||||
- **Target keyword**: [single keyword/intent phrase for this page]
|
||||
- **Frontmatter**:
|
||||
```yaml
|
||||
---
|
||||
title: ...
|
||||
description: ...
|
||||
---
|
||||
```
|
||||
- **Links**: [List at least 3 internal docs links for non-reference pages; if
|
||||
this is a main feature page, include one relevant `zed.dev` marketing link]
|
||||
- **Suggestion**: [Specific text or description of what to add/change]
|
||||
- **Full-file brand pass**: [Required: yes. Note any additional voice edits
|
||||
elsewhere in the same file needed to pass rubric across the entire file.]
|
||||
- **Brand voice scorecard**:
|
||||
|
||||
| Criterion | Score | Notes |
|
||||
| -------------------- | ----- | ----- |
|
||||
| Technical Grounding | /5 | |
|
||||
| Natural Syntax | /5 | |
|
||||
| Quiet Confidence | /5 | |
|
||||
| Developer Respect | /5 | |
|
||||
| Information Priority | /5 | |
|
||||
| Specificity | /5 | |
|
||||
| Voice Consistency | /5 | |
|
||||
| Earned Claims | /5 | |
|
||||
| **TOTAL** | /40 | |
|
||||
|
||||
Pass threshold: all criteria 4+.
|
||||
|
||||
#### 2. [docs/src/another/file.md]
|
||||
...
|
||||
|
||||
### Notes for Reviewer
|
||||
[Any context or uncertainty worth flagging]
|
||||
```
|
||||
|
||||
### If NO documentation updates are needed:
|
||||
|
||||
```markdown
|
||||
## No Documentation Updates Needed
|
||||
|
||||
**Reason**: [Brief explanation - e.g., "Internal refactoring only", "Test changes", "Bug fix restoring existing behavior"]
|
||||
|
||||
**Changes reviewed**:
|
||||
- [Brief summary of what the code changes do]
|
||||
- [Why they don't affect user-facing documentation]
|
||||
```
|
||||
|
||||
Be conservative. Only suggest documentation changes when there's a clear user-facing impact.
|
||||
OUTPUT_FORMAT
|
||||
|
||||
log "Context assembled: $(wc -l < "$TMPDIR/prompt.md" | tr -d ' ') lines"
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Step 4: Run the analysis
|
||||
# ============================================================================
|
||||
|
||||
run_analysis() {
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo -e "${GREEN}=== DRY RUN: Assembled Context ===${NC}"
|
||||
echo ""
|
||||
echo "Output mode: $OUTPUT_MODE"
|
||||
echo "Preview callout: $ADD_PREVIEW_CALLOUT"
|
||||
if [[ "$OUTPUT_MODE" == "batch" ]]; then
|
||||
echo "Batch file: $BATCH_FILE"
|
||||
fi
|
||||
echo ""
|
||||
cat "$TMPDIR/prompt.md"
|
||||
echo ""
|
||||
echo -e "${GREEN}=== End Context ===${NC}"
|
||||
echo ""
|
||||
echo "To run for real, remove --dry-run flag"
|
||||
return
|
||||
fi
|
||||
|
||||
# Check for droid CLI
|
||||
if ! command -v droid &> /dev/null; then
|
||||
error "droid CLI required. Install from: https://app.factory.ai/cli"
|
||||
fi
|
||||
|
||||
log "Running analysis with model: $MODEL"
|
||||
|
||||
# Run the LLM
|
||||
local suggestions
|
||||
suggestions=$(droid exec -m "$MODEL" -f "$TMPDIR/prompt.md")
|
||||
|
||||
# Handle output based on mode
|
||||
if [[ "$OUTPUT_MODE" == "batch" ]]; then
|
||||
append_to_batch "$suggestions"
|
||||
else
|
||||
output_immediate "$suggestions"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Output handlers
|
||||
# ============================================================================
|
||||
|
||||
append_to_batch() {
|
||||
local suggestions="$1"
|
||||
|
||||
# Check if suggestions indicate no updates needed
|
||||
if echo "$suggestions" | grep -q "No Documentation Updates Needed"; then
|
||||
log "No documentation updates needed, skipping batch"
|
||||
echo "$suggestions"
|
||||
return
|
||||
fi
|
||||
|
||||
# Create batch directory if needed
|
||||
mkdir -p "$BATCH_DIR"
|
||||
|
||||
# Get PR info if available
|
||||
local pr_number=""
|
||||
local pr_title=""
|
||||
if [[ -f "$TMPDIR/pr_number" ]]; then
|
||||
pr_number=$(cat "$TMPDIR/pr_number")
|
||||
pr_title=$(cat "$TMPDIR/pr_title")
|
||||
fi
|
||||
|
||||
# Initialize batch file if it doesn't exist
|
||||
if [[ ! -f "$BATCH_FILE" ]]; then
|
||||
cat > "$BATCH_FILE" << 'BATCH_HEADER'
|
||||
# Pending Documentation Suggestions
|
||||
|
||||
This file contains batched documentation suggestions for the next Preview release.
|
||||
Run `script/docs-suggest-publish` to create a PR from these suggestions.
|
||||
|
||||
---
|
||||
|
||||
BATCH_HEADER
|
||||
fi
|
||||
|
||||
# Append suggestions with metadata
|
||||
{
|
||||
echo ""
|
||||
echo "## PR #$pr_number: $pr_title"
|
||||
echo ""
|
||||
echo "_Added: $(date -u +%Y-%m-%dT%H:%M:%SZ)_"
|
||||
echo ""
|
||||
echo "$suggestions"
|
||||
echo ""
|
||||
echo "---"
|
||||
} >> "$BATCH_FILE"
|
||||
|
||||
echo -e "${GREEN}Suggestions batched to:${NC} $BATCH_FILE"
|
||||
echo ""
|
||||
echo "Batched suggestions for PR #$pr_number"
|
||||
echo "Run 'script/docs-suggest-publish' when ready to create the docs PR."
|
||||
}
|
||||
|
||||
output_immediate() {
|
||||
local suggestions="$1"
|
||||
|
||||
if [[ -n "$OUTPUT" ]]; then
|
||||
echo "$suggestions" > "$OUTPUT"
|
||||
echo "Suggestions written to: $OUTPUT"
|
||||
else
|
||||
echo "$suggestions"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Main
|
||||
# ============================================================================
|
||||
|
||||
main() {
|
||||
get_diff
|
||||
filter_changes
|
||||
assemble_context
|
||||
run_analysis
|
||||
}
|
||||
|
||||
main
|
||||
591
script/docs-suggest-publish
Executable file
591
script/docs-suggest-publish
Executable file
@@ -0,0 +1,591 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Create a draft documentation PR by auto-applying batched suggestions.
|
||||
#
|
||||
# Usage:
|
||||
# script/docs-suggest-publish [--dry-run] [--model MODEL]
|
||||
#
|
||||
# This script:
|
||||
# 1. Reads pending suggestions from the docs/suggestions-pending branch
|
||||
# 2. Uses Droid to apply suggestions in batches (default 10 per batch)
|
||||
# 3. Runs docs formatting
|
||||
# 4. Validates docs build (action references, JSON schemas, links)
|
||||
# 5. Creates a draft PR for human review/merge
|
||||
# 6. Optionally resets the suggestions branch after successful PR creation
|
||||
#
|
||||
# Options:
|
||||
# --dry-run Show what would be done without creating PR
|
||||
# --keep-queue Don't reset the suggestions branch after PR creation
|
||||
# --model MODEL Override Droid model used for auto-apply
|
||||
# --batch-size N Suggestions per Droid invocation (default: 10)
|
||||
# --skip-validation Skip the docs build validation step
|
||||
# --verbose Show detailed progress
|
||||
#
|
||||
# Run this as part of the preview release workflow.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
DRY_RUN=false
|
||||
KEEP_QUEUE=false
|
||||
VERBOSE=false
|
||||
SKIP_VALIDATION=false
|
||||
BATCH_SIZE=10
|
||||
MODEL="${DROID_MODEL:-claude-sonnet-4-5-latest}"
|
||||
|
||||
SUGGESTIONS_BRANCH="docs/suggestions-pending"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m'
|
||||
|
||||
log() {
|
||||
if [[ "$VERBOSE" == "true" ]]; then
|
||||
echo -e "${BLUE}[docs-publish]${NC} $*" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}Error:${NC} $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift
|
||||
;;
|
||||
--keep-queue)
|
||||
KEEP_QUEUE=true
|
||||
shift
|
||||
;;
|
||||
--verbose)
|
||||
VERBOSE=true
|
||||
shift
|
||||
;;
|
||||
--model)
|
||||
MODEL="$2"
|
||||
shift 2
|
||||
;;
|
||||
--batch-size)
|
||||
BATCH_SIZE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--skip-validation)
|
||||
SKIP_VALIDATION=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
head -30 "$0" | tail -28
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
error "Unknown option: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Get repo root
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
# Check if suggestions branch exists
|
||||
log "Checking for suggestions branch..."
|
||||
if ! git ls-remote --exit-code --heads origin "$SUGGESTIONS_BRANCH" > /dev/null 2>&1; then
|
||||
echo "No pending suggestions found (branch $SUGGESTIONS_BRANCH doesn't exist)."
|
||||
echo "Suggestions are queued automatically when PRs are merged to main."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Fetch the suggestions branch
|
||||
log "Fetching suggestions branch..."
|
||||
git fetch origin "$SUGGESTIONS_BRANCH"
|
||||
|
||||
# Check for manifest
|
||||
if ! git show "origin/$SUGGESTIONS_BRANCH:manifest.json" > /dev/null 2>&1; then
|
||||
echo "No manifest found on suggestions branch."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read manifest
|
||||
MANIFEST=$(git show "origin/$SUGGESTIONS_BRANCH:manifest.json")
|
||||
SUGGESTION_COUNT=$(echo "$MANIFEST" | jq '.suggestions | length')
|
||||
|
||||
if [[ "$SUGGESTION_COUNT" -eq 0 ]]; then
|
||||
echo "No pending suggestions in queue."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Found $SUGGESTION_COUNT pending suggestion(s):"
|
||||
echo ""
|
||||
echo "$MANIFEST" | jq -r '.suggestions[] | " PR #\(.pr): \(.title)"'
|
||||
echo ""
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo -e "${YELLOW}=== DRY RUN ===${NC}"
|
||||
echo ""
|
||||
echo "Would auto-apply suggestions to docs via Droid and create a draft PR."
|
||||
echo "Model: $MODEL"
|
||||
echo ""
|
||||
|
||||
# Show each suggestion file
|
||||
for file in $(echo "$MANIFEST" | jq -r '.suggestions[].file'); do
|
||||
echo "--- $file ---"
|
||||
git show "origin/$SUGGESTIONS_BRANCH:$file" 2>/dev/null || echo "(file not found)"
|
||||
echo ""
|
||||
done
|
||||
|
||||
echo -e "${YELLOW}=== END DRY RUN ===${NC}"
|
||||
echo ""
|
||||
echo "Run without --dry-run to create the PR."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ensure clean working state (ignore untracked files with grep -v '??')
|
||||
if [[ -n "$(git status --porcelain | grep -v '^??' || true)" ]]; then
|
||||
error "Working directory has uncommitted changes. Please commit or stash first."
|
||||
fi
|
||||
|
||||
REQUIRED_COMMANDS=(git gh jq droid)
|
||||
if [[ "$SKIP_VALIDATION" != "true" ]]; then
|
||||
REQUIRED_COMMANDS+=(mdbook)
|
||||
fi
|
||||
for command in "${REQUIRED_COMMANDS[@]}"; do
|
||||
if ! command -v "$command" > /dev/null 2>&1; then
|
||||
error "Required command not found: $command"
|
||||
fi
|
||||
done
|
||||
|
||||
# Remember current branch
|
||||
ORIGINAL_BRANCH=$(git branch --show-current)
|
||||
log "Current branch: $ORIGINAL_BRANCH"
|
||||
|
||||
# Create new branch for docs PR from latest main
|
||||
git fetch origin main
|
||||
DOCS_BRANCH="docs/preview-auto-$(date +%Y-%m-%d-%H%M%S)"
|
||||
log "Creating docs branch: $DOCS_BRANCH"
|
||||
|
||||
git checkout -b "$DOCS_BRANCH" origin/main
|
||||
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
APPLY_SUMMARY_FILE="$TMPDIR/apply-summary.md"
|
||||
touch "$APPLY_SUMMARY_FILE"
|
||||
|
||||
# Collect suggestion files into an array
|
||||
SUGGESTION_FILES=()
|
||||
while IFS= read -r file; do
|
||||
SUGGESTION_FILES+=("$file")
|
||||
done < <(echo "$MANIFEST" | jq -r '.suggestions[].file')
|
||||
|
||||
# Determine which PRs are already in the latest stable release.
|
||||
# Suggestions queued with --preview may reference features that shipped in stable
|
||||
# by the time this script runs, so their Preview callouts should be stripped.
|
||||
STABLE_PRS=()
|
||||
STABLE_TAG=$(git tag -l 'v*' --sort=-v:refname | grep -v 'pre' | head -1 || true)
|
||||
if [[ -n "$STABLE_TAG" ]]; then
|
||||
log "Latest stable release tag: $STABLE_TAG"
|
||||
for file in "${SUGGESTION_FILES[@]}"; do
|
||||
pr_num=$(echo "$MANIFEST" | jq -r --arg f "$file" '.suggestions[] | select(.file == $f) | .pr')
|
||||
# Find the merge commit for this PR
|
||||
merge_sha=$(gh pr view "$pr_num" --json mergeCommit --jq '.mergeCommit.oid' 2>/dev/null || true)
|
||||
if [[ -n "$merge_sha" ]] && git merge-base --is-ancestor "$merge_sha" "$STABLE_TAG" 2>/dev/null; then
|
||||
STABLE_PRS+=("$pr_num")
|
||||
log "PR #$pr_num is in stable ($STABLE_TAG)"
|
||||
fi
|
||||
done
|
||||
if [[ ${#STABLE_PRS[@]} -gt 0 ]]; then
|
||||
echo -e "${YELLOW}Note:${NC} ${#STABLE_PRS[@]} suggestion(s) are for PRs already in stable ($STABLE_TAG)."
|
||||
echo " Preview callouts will be stripped for: ${STABLE_PRS[*]}"
|
||||
echo ""
|
||||
fi
|
||||
else
|
||||
log "No stable release tag found, treating all suggestions as preview-only"
|
||||
fi
|
||||
|
||||
# Determine which PRs touch code gated behind feature flags.
|
||||
# Features behind flags aren't generally available and shouldn't be documented yet.
|
||||
FLAGGED_PRS=()
|
||||
FLAGS_FILE="$REPO_ROOT/crates/feature_flags/src/flags.rs"
|
||||
if [[ -f "$FLAGS_FILE" ]]; then
|
||||
# Extract feature flag struct names (e.g. SubagentsFeatureFlag)
|
||||
FLAG_NAMES=$(grep -oE 'pub struct \w+FeatureFlag' "$FLAGS_FILE" | awk '{print $3}')
|
||||
if [[ -n "$FLAG_NAMES" ]]; then
|
||||
FLAG_PATTERN=$(echo "$FLAG_NAMES" | tr '\n' '|' | sed 's/|$//')
|
||||
log "Feature flags found: $(echo "$FLAG_NAMES" | tr '\n' ' ')"
|
||||
for file in "${SUGGESTION_FILES[@]}"; do
|
||||
pr_num=$(echo "$MANIFEST" | jq -r --arg f "$file" '.suggestions[] | select(.file == $f) | .pr')
|
||||
# Skip PRs already marked as stable (no need to double-check)
|
||||
is_already_stable=false
|
||||
for stable_pr in "${STABLE_PRS[@]+"${STABLE_PRS[@]}"}"; do
|
||||
if [[ "$stable_pr" == "$pr_num" ]]; then
|
||||
is_already_stable=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$is_already_stable" == "true" ]]; then
|
||||
continue
|
||||
fi
|
||||
# Check if the PR diff references any feature flag
|
||||
pr_diff=$(gh pr diff "$pr_num" 2>/dev/null || true)
|
||||
if [[ -n "$pr_diff" ]] && echo "$pr_diff" | grep -qE "$FLAG_PATTERN"; then
|
||||
matched_flags=$(echo "$pr_diff" | grep -oE "$FLAG_PATTERN" | sort -u | tr '\n' ', ' | sed 's/,$//')
|
||||
FLAGGED_PRS+=("$pr_num")
|
||||
log "PR #$pr_num is behind feature flag(s): $matched_flags"
|
||||
fi
|
||||
done
|
||||
if [[ ${#FLAGGED_PRS[@]} -gt 0 ]]; then
|
||||
echo -e "${YELLOW}Note:${NC} ${#FLAGGED_PRS[@]} suggestion(s) are for features behind feature flags."
|
||||
echo " These will be skipped: ${FLAGGED_PRS[*]}"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
else
|
||||
log "Feature flags file not found, skipping flag detection"
|
||||
fi
|
||||
|
||||
# Split into batches
|
||||
TOTAL=${#SUGGESTION_FILES[@]}
|
||||
BATCH_COUNT=$(( (TOTAL + BATCH_SIZE - 1) / BATCH_SIZE ))
|
||||
|
||||
if [[ "$BATCH_COUNT" -gt 1 ]]; then
|
||||
echo "Processing $TOTAL suggestions in $BATCH_COUNT batches of up to $BATCH_SIZE..."
|
||||
else
|
||||
echo "Processing $TOTAL suggestions..."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
for (( batch=0; batch<BATCH_COUNT; batch++ )); do
|
||||
START=$(( batch * BATCH_SIZE ))
|
||||
END=$(( START + BATCH_SIZE ))
|
||||
if [[ "$END" -gt "$TOTAL" ]]; then
|
||||
END=$TOTAL
|
||||
fi
|
||||
|
||||
BATCH_NUM=$(( batch + 1 ))
|
||||
BATCH_SUGGESTIONS_FILE="$TMPDIR/batch-${BATCH_NUM}-suggestions.md"
|
||||
BATCH_PROMPT_FILE="$TMPDIR/batch-${BATCH_NUM}-prompt.md"
|
||||
BATCH_SUMMARY_FILE="$TMPDIR/batch-${BATCH_NUM}-summary.md"
|
||||
|
||||
echo -e "${BLUE}Batch $BATCH_NUM/$BATCH_COUNT${NC} (suggestions $(( START + 1 ))-$END of $TOTAL)"
|
||||
|
||||
# Combine suggestion files for this batch, skipping flagged PRs and annotating stable PRs
|
||||
BATCH_HAS_SUGGESTIONS=false
|
||||
for (( i=START; i<END; i++ )); do
|
||||
file="${SUGGESTION_FILES[$i]}"
|
||||
pr_num=$(echo "$MANIFEST" | jq -r --arg f "$file" '.suggestions[] | select(.file == $f) | .pr')
|
||||
|
||||
# Skip PRs behind feature flags entirely
|
||||
is_flagged=false
|
||||
for flagged_pr in "${FLAGGED_PRS[@]+"${FLAGGED_PRS[@]}"}"; do
|
||||
if [[ "$flagged_pr" == "$pr_num" ]]; then
|
||||
is_flagged=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$is_flagged" == "true" ]]; then
|
||||
log "Skipping PR #$pr_num (behind feature flag)"
|
||||
{
|
||||
echo "### Skipped: PR #$pr_num"
|
||||
echo ""
|
||||
echo "This PR is behind a feature flag and was not applied."
|
||||
echo ""
|
||||
} >> "$APPLY_SUMMARY_FILE"
|
||||
continue
|
||||
fi
|
||||
|
||||
BATCH_HAS_SUGGESTIONS=true
|
||||
|
||||
# Check if PR is already in stable
|
||||
is_stable=false
|
||||
for stable_pr in "${STABLE_PRS[@]+"${STABLE_PRS[@]}"}"; do
|
||||
if [[ "$stable_pr" == "$pr_num" ]]; then
|
||||
is_stable=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
{
|
||||
echo "## Source: $file"
|
||||
if [[ "$is_stable" == "true" ]]; then
|
||||
echo ""
|
||||
echo "> **ALREADY IN STABLE**: PR #$pr_num shipped in $STABLE_TAG."
|
||||
echo "> Do NOT add Preview or Changed-in-Preview callouts for this suggestion."
|
||||
echo "> Apply the documentation content only, without any preview-related callouts."
|
||||
fi
|
||||
echo ""
|
||||
git show "origin/$SUGGESTIONS_BRANCH:$file" 2>/dev/null || error "Suggestion file missing: $file"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
} >> "$BATCH_SUGGESTIONS_FILE"
|
||||
done
|
||||
|
||||
# Skip this batch if all its suggestions were flagged
|
||||
if [[ "$BATCH_HAS_SUGGESTIONS" == "false" ]]; then
|
||||
echo -e " ${YELLOW}Batch $BATCH_NUM skipped (all suggestions behind feature flags)${NC}"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Build auto-apply prompt for this batch
|
||||
cat > "$BATCH_PROMPT_FILE" << 'EOF'
|
||||
# Documentation Auto-Apply Request (Preview Release)
|
||||
|
||||
Apply all queued documentation suggestions below directly to docs files in this repository.
|
||||
|
||||
Before making edits, read and follow these rule files:
|
||||
- `.rules`
|
||||
- `docs/.rules`
|
||||
|
||||
## Required behavior
|
||||
|
||||
1. Apply concrete documentation edits (not suggestion text) to the appropriate files.
|
||||
2. Edit only docs content files under `docs/src/` unless a suggestion explicitly requires another docs path.
|
||||
3. For every docs file you modify, run a full-file brand voice pass (entire file, not only edited sections).
|
||||
4. Enforce the brand rubric exactly; final file content must score 4+ on every criterion:
|
||||
- Technical Grounding
|
||||
- Natural Syntax
|
||||
- Quiet Confidence
|
||||
- Developer Respect
|
||||
- Information Priority
|
||||
- Specificity
|
||||
- Voice Consistency
|
||||
- Earned Claims
|
||||
5. Keep SEO/frontmatter/linking requirements from the suggestions where applicable.
|
||||
6. Keep preview callout semantics correct:
|
||||
- Additive features: `> **Preview:** ...`
|
||||
- Behavior modifications: `> **Changed in Preview (vX.XXX).** ...`
|
||||
- **Exception**: Suggestions marked "ALREADY IN STABLE" must NOT get any preview callouts.
|
||||
These features already shipped in a stable release. Apply the content changes only.
|
||||
- Suggestions for features behind feature flags have been pre-filtered and excluded.
|
||||
If you encounter references to feature-flagged functionality, do not document it.
|
||||
7. If a suggestion is too ambiguous to apply safely, skip it and explain why in the summary.
|
||||
8. **Do not invent `{#kb}` or `{#action}` references.** Only use action names that already
|
||||
appear in the existing docs files you are editing. If unsure whether an action name is
|
||||
valid, use plain text instead. The docs build validates all action references against
|
||||
the compiled binary and will reject unknown names.
|
||||
|
||||
## Output format (after making edits)
|
||||
|
||||
Return markdown with:
|
||||
|
||||
- `## Applied Suggestions`
|
||||
- `## Skipped Suggestions`
|
||||
- `## Files Updated`
|
||||
- `## Brand Voice Verification` (one line per updated file confirming full-file pass)
|
||||
|
||||
Do not include a patch in the response; apply edits directly to files.
|
||||
|
||||
## Queued Suggestions
|
||||
|
||||
EOF
|
||||
|
||||
cat "$BATCH_SUGGESTIONS_FILE" >> "$BATCH_PROMPT_FILE"
|
||||
|
||||
log "Running Droid auto-apply (batch $BATCH_NUM) with model: $MODEL"
|
||||
if ! droid exec -m "$MODEL" -f "$BATCH_PROMPT_FILE" --auto high > "$BATCH_SUMMARY_FILE" 2>&1; then
|
||||
echo "Droid exec output (batch $BATCH_NUM):"
|
||||
cat "$BATCH_SUMMARY_FILE"
|
||||
error "Droid exec failed on batch $BATCH_NUM. See output above."
|
||||
fi
|
||||
|
||||
# Append batch summary
|
||||
{
|
||||
echo "### Batch $BATCH_NUM"
|
||||
echo ""
|
||||
cat "$BATCH_SUMMARY_FILE"
|
||||
echo ""
|
||||
} >> "$APPLY_SUMMARY_FILE"
|
||||
|
||||
echo -e " ${GREEN}Batch $BATCH_NUM complete${NC}"
|
||||
done
|
||||
echo ""
|
||||
|
||||
log "All batches completed, checking results..."
|
||||
|
||||
if [[ -n "$(git status --porcelain | grep -v '^??' | grep -vE '^.. docs/' || true)" ]]; then
|
||||
error "Auto-apply modified non-doc files. Revert and re-run."
|
||||
fi
|
||||
|
||||
if [[ -z "$(git status --porcelain docs/ | grep '^.. docs/src/' || true)" ]]; then
|
||||
error "Auto-apply produced no docs/src changes."
|
||||
fi
|
||||
|
||||
log "Running docs formatter"
|
||||
./script/prettier --write
|
||||
|
||||
if [[ -z "$(git status --porcelain docs/ | grep '^.. docs/src/' || true)" ]]; then
|
||||
error "No docs/src changes remain after formatting; aborting PR creation."
|
||||
fi
|
||||
|
||||
# Validate docs build before creating PR
|
||||
if [[ "$SKIP_VALIDATION" != "true" ]]; then
|
||||
echo "Validating docs build..."
|
||||
log "Generating action metadata..."
|
||||
if ! ./script/generate-action-metadata > /dev/null 2>&1; then
|
||||
echo -e "${YELLOW}Warning:${NC} Could not generate action metadata (cargo build may have failed)."
|
||||
echo "Skipping docs build validation. CI will still catch errors."
|
||||
else
|
||||
VALIDATION_DIR="$TMPDIR/docs-validation"
|
||||
if ! mdbook build ./docs --dest-dir="$VALIDATION_DIR" 2>"$TMPDIR/validation-errors.txt"; then
|
||||
echo ""
|
||||
echo -e "${RED}Docs build validation failed:${NC}"
|
||||
cat "$TMPDIR/validation-errors.txt"
|
||||
echo ""
|
||||
error "Fix the errors above and re-run, or use --skip-validation to bypass."
|
||||
fi
|
||||
echo -e "${GREEN}Docs build validation passed.${NC}"
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Build PR body from suggestions
|
||||
PR_BODY_FILE="$TMPDIR/pr-body.md"
|
||||
cat > "$PR_BODY_FILE" << 'EOF'
|
||||
# Documentation Updates for Preview Release
|
||||
|
||||
This draft PR auto-applies queued documentation suggestions collected from
|
||||
recently merged PRs.
|
||||
|
||||
## How to Use This PR
|
||||
|
||||
1. Review the applied changes file-by-file.
|
||||
2. Verify brand voice on each touched file as a full-file pass.
|
||||
3. Ensure docs use the correct callout type:
|
||||
- Additive features: Preview callout
|
||||
- Behavior modifications: Changed in Preview callout
|
||||
4. Merge when ready.
|
||||
|
||||
## Auto-Apply Summary
|
||||
|
||||
EOF
|
||||
|
||||
cat "$APPLY_SUMMARY_FILE" >> "$PR_BODY_FILE"
|
||||
|
||||
cat >> "$PR_BODY_FILE" << 'EOF'
|
||||
|
||||
## Preview Callouts
|
||||
|
||||
Use the Preview callout for new/additive features:
|
||||
|
||||
```markdown
|
||||
> **Preview:** This feature is available in Zed Preview. It will be included in the next Stable release.
|
||||
```
|
||||
|
||||
Use this callout for behavior modifications to existing functionality:
|
||||
|
||||
```markdown
|
||||
> **Changed in Preview (v0.XXX).** See [release notes](/releases#0.XXX).
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pending Suggestions
|
||||
|
||||
EOF
|
||||
|
||||
# Append each suggestion to PR body
|
||||
for file in $(echo "$MANIFEST" | jq -r '.suggestions[].file'); do
|
||||
log "Adding $file to PR body..."
|
||||
echo "" >> "$PR_BODY_FILE"
|
||||
git show "origin/$SUGGESTIONS_BRANCH:$file" >> "$PR_BODY_FILE" 2>/dev/null || true
|
||||
echo "" >> "$PR_BODY_FILE"
|
||||
echo "---" >> "$PR_BODY_FILE"
|
||||
done
|
||||
|
||||
# Add tracking info
|
||||
cat >> "$PR_BODY_FILE" << EOF
|
||||
|
||||
## PRs Included
|
||||
|
||||
EOF
|
||||
|
||||
echo "$MANIFEST" | jq -r '.suggestions[] | "- [PR #\(.pr)](\(.file)): \(.title)"' >> "$PR_BODY_FILE"
|
||||
|
||||
cat >> "$PR_BODY_FILE" << 'EOF'
|
||||
|
||||
Release Notes:
|
||||
|
||||
- N/A
|
||||
EOF
|
||||
|
||||
git add docs/
|
||||
git commit -m "docs: Auto-apply preview release suggestions
|
||||
|
||||
Auto-applied queued documentation suggestions from:
|
||||
$(echo "$MANIFEST" | jq -r '.suggestions[] | "- PR #\(.pr)"')
|
||||
|
||||
Generated with script/docs-suggest-publish for human review in draft PR."
|
||||
|
||||
# Push and create PR
|
||||
log "Pushing branch..."
|
||||
git push -u origin "$DOCS_BRANCH"
|
||||
|
||||
log "Creating PR..."
|
||||
PR_URL=$(gh pr create \
|
||||
--draft \
|
||||
--title "docs: Apply preview release suggestions" \
|
||||
--body-file "$PR_BODY_FILE")
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}PR created:${NC} $PR_URL"
|
||||
|
||||
# Reset suggestions branch if not keeping
|
||||
if [[ "$KEEP_QUEUE" != "true" ]]; then
|
||||
echo ""
|
||||
echo "Resetting suggestions queue..."
|
||||
|
||||
git checkout --orphan "${SUGGESTIONS_BRANCH}-reset"
|
||||
git rm -rf . > /dev/null 2>&1 || true
|
||||
|
||||
cat > README.md << 'EOF'
|
||||
# Documentation Suggestions Queue
|
||||
|
||||
This branch contains batched documentation suggestions for the next Preview release.
|
||||
|
||||
Each file represents suggestions from a merged PR. At preview branch cut time,
|
||||
run `script/docs-suggest-publish` to create a documentation PR from these suggestions.
|
||||
|
||||
## Structure
|
||||
|
||||
- `suggestions/PR-XXXXX.md` - Suggestions for PR #XXXXX
|
||||
- `manifest.json` - Index of all pending suggestions
|
||||
|
||||
## Workflow
|
||||
|
||||
1. PRs merged to main trigger documentation analysis
|
||||
2. Suggestions are committed here as individual files
|
||||
3. At preview release, suggestions are collected into a docs PR
|
||||
4. After docs PR is created, this branch is reset
|
||||
EOF
|
||||
|
||||
mkdir -p suggestions
|
||||
echo '{"suggestions":[]}' > manifest.json
|
||||
git add README.md suggestions manifest.json
|
||||
git commit -m "Reset documentation suggestions queue
|
||||
|
||||
Previous suggestions published in: $PR_URL"
|
||||
|
||||
# Force push required: replacing the orphan suggestions branch with a clean slate
|
||||
git push -f origin "${SUGGESTIONS_BRANCH}-reset:$SUGGESTIONS_BRANCH"
|
||||
git checkout "$ORIGINAL_BRANCH"
|
||||
git branch -D "${SUGGESTIONS_BRANCH}-reset"
|
||||
|
||||
echo "Suggestions queue reset."
|
||||
else
|
||||
git checkout "$ORIGINAL_BRANCH"
|
||||
echo ""
|
||||
echo "Suggestions queue kept (--keep-queue). Remember to reset manually after PR is merged."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}Done!${NC}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Review the draft PR and verify all auto-applied docs changes"
|
||||
echo "2. Confirm full-file brand voice pass for each touched docs file"
|
||||
echo "3. Mark ready for review and merge"
|
||||
61
script/download-wasi-sdk
Executable file
61
script/download-wasi-sdk
Executable file
@@ -0,0 +1,61 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Check if ./target/wasi-sdk exists
|
||||
if [ ! -d "./target/wasi-sdk" ]; then
|
||||
echo "WASI SDK not found, downloading v25..."
|
||||
|
||||
# Determine OS and architecture
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
ARCH=$(uname -m)
|
||||
|
||||
# Map architecture names to WASI SDK format
|
||||
case $ARCH in
|
||||
x86_64)
|
||||
ARCH="x86_64"
|
||||
;;
|
||||
arm64|aarch64)
|
||||
ARCH="arm64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported architecture: $ARCH"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Map OS names to WASI SDK format
|
||||
case $OS in
|
||||
darwin)
|
||||
OS="macos"
|
||||
;;
|
||||
linux)
|
||||
OS="linux"
|
||||
;;
|
||||
mingw*|msys*|cygwin*)
|
||||
OS="mingw"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported OS: $OS"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Construct download URL
|
||||
WASI_SDK_VERSION="25"
|
||||
WASI_SDK_URL="https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_VERSION}.0-${ARCH}-${OS}.tar.gz"
|
||||
|
||||
echo "Downloading from: $WASI_SDK_URL"
|
||||
|
||||
# Create target directory if it doesn't exist
|
||||
mkdir -p ./target
|
||||
|
||||
# Download and extract
|
||||
curl -fL "$WASI_SDK_URL" | tar -xz -C ./target
|
||||
|
||||
# Rename the extracted directory to wasi-sdk
|
||||
mv "./target/wasi-sdk-${WASI_SDK_VERSION}.0-${ARCH}-${OS}" "./target/wasi-sdk"
|
||||
|
||||
echo "WASI SDK v25 installed successfully"
|
||||
else
|
||||
echo "WASI SDK already exists at ./target/wasi-sdk"
|
||||
fi
|
||||
120
script/draft-release-notes
Executable file
120
script/draft-release-notes
Executable file
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node --redirect-warnings=/dev/null
|
||||
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
main();
|
||||
|
||||
async function main() {
|
||||
let version = process.argv[2];
|
||||
let channel = process.argv[3];
|
||||
let parts = version.split(".");
|
||||
|
||||
if (
|
||||
process.argv.length != 4 ||
|
||||
parts.length != 3 ||
|
||||
parts.find((part) => isNaN(part)) != null ||
|
||||
(channel != "stable" && channel != "preview")
|
||||
) {
|
||||
console.log("Usage: draft-release-notes <version> {stable|preview}");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// currently we can only draft notes for patch releases.
|
||||
if (parts[2] === 0) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let priorVersion = [parts[0], parts[1], parts[2] - 1].join(".");
|
||||
let suffix = channel == "preview" ? "-pre" : "";
|
||||
let [tag, priorTag] = [`v${version}${suffix}`, `v${priorVersion}${suffix}`];
|
||||
|
||||
try {
|
||||
execFileSync("rm", ["-rf", "target/shallow_clone"]);
|
||||
execFileSync("git", [
|
||||
"clone",
|
||||
"https://github.com/zed-industries/zed",
|
||||
"target/shallow_clone",
|
||||
"--filter=tree:0",
|
||||
"--no-checkout",
|
||||
"--branch",
|
||||
tag,
|
||||
"--depth",
|
||||
100,
|
||||
]);
|
||||
execFileSync("git", ["-C", "target/shallow_clone", "rev-parse", "--verify", tag]);
|
||||
try {
|
||||
execFileSync("git", ["-C", "target/shallow_clone", "rev-parse", "--verify", priorTag]);
|
||||
} catch (e) {
|
||||
console.error(`Prior tag ${priorTag} not found`);
|
||||
process.exit(0);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e.stderr.toString());
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const newCommits = getCommits(priorTag, tag);
|
||||
|
||||
let releaseNotes = [];
|
||||
let missing = [];
|
||||
let skipped = [];
|
||||
|
||||
for (const commit of newCommits) {
|
||||
let link = "https://github.com/zed-industries/zed/pull/" + commit.pr;
|
||||
let notes = commit.releaseNotes;
|
||||
if (commit.pr == "") {
|
||||
link = "https://github.com/zed-industries/zed/commits/" + commit.hash;
|
||||
} else if (!notes.includes("zed-industries/zed/issues")) {
|
||||
notes = notes + " ([#" + commit.pr + "](" + link + "))";
|
||||
}
|
||||
|
||||
if (commit.releaseNotes == "") {
|
||||
missing.push("- MISSING " + commit.firstLine + " " + link);
|
||||
} else if (commit.releaseNotes.startsWith("- N/A")) {
|
||||
skipped.push("- N/A " + commit.firstLine + " " + link);
|
||||
} else {
|
||||
releaseNotes.push(notes);
|
||||
}
|
||||
}
|
||||
|
||||
if (releaseNotes.length === 0) {
|
||||
const compareUrl = `https://github.com/zed-industries/zed/compare/${priorTag}...${tag}#commits_bucket`;
|
||||
console.log(`No public-facing changes in this release. [View the commits](${compareUrl}).\n`);
|
||||
} else {
|
||||
console.log(releaseNotes.join("\n") + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
function getCommits(oldTag, newTag) {
|
||||
const pullRequestNumbers = execFileSync(
|
||||
"git",
|
||||
["-C", "target/shallow_clone", "log", `${oldTag}..${newTag}`, "--format=DIVIDER\n%H|||%B"],
|
||||
{ encoding: "utf8" },
|
||||
)
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split("DIVIDER\n")
|
||||
.filter((commit) => commit.length > 0)
|
||||
.map((commit) => {
|
||||
let [hash, firstLine] = commit.split("\n")[0].split("|||");
|
||||
let cherryPick = firstLine.match(/\(cherry-pick #([0-9]+)\)/)?.[1] || "";
|
||||
let pr = firstLine.match(/\(#(\d+)\)$/)?.[1] || "";
|
||||
let releaseNotes = (commit.split(/Release notes:.*\n/i)[1] || "")
|
||||
.split("\n\n")[0]
|
||||
.trim()
|
||||
.replace(/\n(?![\n-])/g, " ");
|
||||
|
||||
if (releaseNotes.includes("<public_issue_number_if_exists>")) {
|
||||
releaseNotes = "";
|
||||
}
|
||||
|
||||
return {
|
||||
hash,
|
||||
pr,
|
||||
cherryPick,
|
||||
releaseNotes,
|
||||
firstLine,
|
||||
};
|
||||
});
|
||||
|
||||
return pullRequestNumbers;
|
||||
}
|
||||
16
script/drop-test-dbs
Executable file
16
script/drop-test-dbs
Executable file
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
databases=$(psql --tuples-only --command "
|
||||
SELECT
|
||||
datname
|
||||
FROM
|
||||
pg_database
|
||||
WHERE
|
||||
datistemplate = false
|
||||
AND datname like 'zed-test-%'
|
||||
")
|
||||
|
||||
for database in $databases; do
|
||||
echo $database
|
||||
dropdb $database
|
||||
done
|
||||
22
script/exit-ci-if-dev-drive-is-full.ps1
Normal file
22
script/exit-ci-if-dev-drive-is-full.ps1
Normal file
@@ -0,0 +1,22 @@
|
||||
param (
|
||||
[Parameter(Mandatory = $true)]
|
||||
[int]$MAX_SIZE_IN_GB
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$PSNativeCommandUseErrorActionPreference = $true
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
|
||||
if (-Not (Test-Path -Path "target")) {
|
||||
Write-Host "target directory does not exist yet"
|
||||
exit 0
|
||||
}
|
||||
|
||||
$current_size_gb = (Get-ChildItem -Recurse -Force -File -Path "target" | Measure-Object -Property Length -Sum).Sum / 1GB
|
||||
|
||||
Write-Host "target directory size: ${current_size_gb}GB. max size: ${MAX_SIZE_IN_GB}GB"
|
||||
|
||||
if ($current_size_gb -gt $MAX_SIZE_IN_GB) {
|
||||
Write-Host "Dev drive is almost full, increase the size first!"
|
||||
exit 1
|
||||
}
|
||||
46
script/flatpak/bundle-flatpak
Executable file
46
script/flatpak/bundle-flatpak
Executable file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/../.."
|
||||
shopt -s extglob
|
||||
|
||||
script/bundle-linux --flatpak
|
||||
archive_match="zed(-[a-zA-Z0-9]+)?-linux-$(uname -m)\.tar\.gz"
|
||||
archive=$(ls "target/release" | grep -E ${archive_match})
|
||||
channel=$(<crates/zed/RELEASE_CHANNEL)
|
||||
|
||||
export CHANNEL="$channel"
|
||||
export ARCHIVE="$archive"
|
||||
if [[ "$channel" == "dev" ]]; then
|
||||
export APP_ID="dev.zed.ZedDev"
|
||||
export APP_NAME="Zed Devel"
|
||||
export BRANDING_LIGHT="#99c1f1"
|
||||
export BRANDING_DARK="#1a5fb4"
|
||||
export ICON_FILE="app-icon-dev"
|
||||
elif [[ "$channel" == "nightly" ]]; then
|
||||
export APP_ID="dev.zed.ZedNightly"
|
||||
export APP_NAME="Zed Nightly"
|
||||
export BRANDING_LIGHT="#e9aa6a"
|
||||
export BRANDING_DARK="#1a5fb4"
|
||||
export ICON_FILE="app-icon-nightly"
|
||||
elif [[ "$channel" == "preview" ]]; then
|
||||
export APP_ID="dev.zed.ZedPreview"
|
||||
export APP_NAME="Zed Preview"
|
||||
export BRANDING_LIGHT="#99c1f1"
|
||||
export BRANDING_DARK="#1a5fb4"
|
||||
export ICON_FILE="app-icon-preview"
|
||||
elif [[ "$channel" == "stable" ]]; then
|
||||
export APP_ID="dev.zed.Zed"
|
||||
export APP_NAME="Zed"
|
||||
export BRANDING_LIGHT="#99c1f1"
|
||||
export BRANDING_DARK="#1a5fb4"
|
||||
export ICON_FILE="app-icon"
|
||||
else
|
||||
echo "Invalid channel: '$channel'"
|
||||
exit
|
||||
fi
|
||||
|
||||
envsubst < "crates/zed/resources/flatpak/manifest-template.json" > "$APP_ID.json"
|
||||
flatpak-builder --user --install --force-clean build "$APP_ID.json"
|
||||
flatpak build-bundle ~/.local/share/flatpak/repo "target/release/$APP_ID.flatpak" "$APP_ID"
|
||||
echo "Created 'target/release/$APP_ID.flatpak'"
|
||||
93
script/flatpak/convert-release-notes.py
Normal file
93
script/flatpak/convert-release-notes.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import os
|
||||
import re
|
||||
import requests
|
||||
import sys
|
||||
import textwrap
|
||||
|
||||
from html import escape
|
||||
|
||||
def clean_line(line: str, in_code_fence: bool) -> str:
|
||||
line = re.sub(r"\(\[(#\d+)\]\([\w|\d\:|\/|\.|\-|_]*\)\)", lambda match: f"[{match.group(1)}]", line)
|
||||
line = re.sub(r"\[(#\d+)\]\([\w|\d\:|\/|\.|\-|_]*\)", lambda match: f"[{match.group(1)}]", line)
|
||||
if not in_code_fence:
|
||||
line = line.strip()
|
||||
|
||||
return escape(line)
|
||||
|
||||
|
||||
def convert_body(body: str) -> str:
|
||||
formatted = ""
|
||||
|
||||
in_code_fence = False
|
||||
in_list = False
|
||||
for line in body.splitlines():
|
||||
line = clean_line(line, in_code_fence)
|
||||
if not line:
|
||||
continue
|
||||
if re.search(r'\[[\w|\d|:|\/|\.|\-|_]*\]\([\w|\d|:|\/|\.|\-|_]*\)', line):
|
||||
continue
|
||||
line = re.sub(r"(?<!\`)`([^`\n]+)`(?!`)", lambda match: f"<code>{match.group(1)}</code>", line)
|
||||
|
||||
contains_code_fence = bool(re.search(r"```", line))
|
||||
is_list = bool(re.search(r"^-\s*", line))
|
||||
|
||||
if in_list and not is_list:
|
||||
formatted += "</ul>\n"
|
||||
if (not in_code_fence and contains_code_fence) or (not in_list and is_list):
|
||||
formatted += "<ul>\n"
|
||||
in_list = is_list
|
||||
in_code_fence = contains_code_fence != in_code_fence
|
||||
|
||||
if is_list:
|
||||
line = re.sub(r"^-\s*", "", line)
|
||||
line = f" <li>{line}</li>"
|
||||
elif in_code_fence or contains_code_fence:
|
||||
line = f" <li><code> {line}</code></li>"
|
||||
else:
|
||||
line = f"<p>{line}</p>"
|
||||
formatted += f"{line}\n"
|
||||
|
||||
if (not in_code_fence and contains_code_fence):
|
||||
formatted += "</ul>\n"
|
||||
if in_code_fence or in_list:
|
||||
formatted += "</ul>\n"
|
||||
return formatted
|
||||
|
||||
def get_release_info(tag: str):
|
||||
url = f"https://api.github.com/repos/zed-industries/zed/releases/tags/{tag}"
|
||||
response = requests.get(url)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
print(f"Failed to fetch release info for tag '{tag}'. Status code: {response.status_code}")
|
||||
quit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.chdir(sys.path[0])
|
||||
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: python convert-release-notes.py <tag> <channel>")
|
||||
sys.exit(1)
|
||||
|
||||
tag = sys.argv[1]
|
||||
channel = sys.argv[2]
|
||||
|
||||
release_info = get_release_info(tag)
|
||||
body = convert_body(release_info["body"])
|
||||
version = tag.removeprefix("v").removesuffix("-pre")
|
||||
date = release_info["published_at"]
|
||||
|
||||
release_info_str = f"<release version=\"{version}\" date=\"{date}\">\n"
|
||||
release_info_str += f" <description>\n"
|
||||
release_info_str += textwrap.indent(body, " " * 8)
|
||||
release_info_str += f" </description>\n"
|
||||
release_info_str += f" <url>https://github.com/zed-industries/zed/releases/tag/{tag}</url>\n"
|
||||
release_info_str += "</release>\n"
|
||||
|
||||
channel_releases_file = f"../../crates/zed/resources/flatpak/release-info/{channel}"
|
||||
with open(channel_releases_file) as f:
|
||||
old_release_info = f.read()
|
||||
with open(channel_releases_file, "w") as f:
|
||||
f.write(textwrap.indent(release_info_str, " " * 8) + old_release_info)
|
||||
print(f"Added release notes from {tag} to '{channel_releases_file}'")
|
||||
9
script/flatpak/deps
Executable file
9
script/flatpak/deps
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
|
||||
flatpak remote-add --if-not-exists --user flathub https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
|
||||
arch=$(arch)
|
||||
fd_version=23.08
|
||||
flatpak install -y --user org.freedesktop.Platform/${arch}/${fd_version}
|
||||
flatpak install -y --user org.freedesktop.Sdk/${arch}/${fd_version}
|
||||
flatpak install -y --user org.freedesktop.Sdk.Extension.rust-stable/${arch}/${fd_version}
|
||||
35
script/freebsd
Executable file
35
script/freebsd
Executable file
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -xeuo pipefail
|
||||
|
||||
# if root or if sudo/unavailable, define an empty variable
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
maysudo=''
|
||||
else
|
||||
maysudo="$(command -v sudo || command -v doas || true)"
|
||||
fi
|
||||
|
||||
function finalize {
|
||||
# after packages install (curl, etc), get the rust toolchain
|
||||
which rustup >/dev/null 2>&1 || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
echo "Finished installing FreeBSD dependencies with script/freebsd"
|
||||
}
|
||||
|
||||
# FreeBSD
|
||||
# https://www.freebsd.org/ports/
|
||||
pkg=$(command -v pkg || true)
|
||||
if [[ -n $pkg ]]; then
|
||||
deps=(
|
||||
cmake
|
||||
gcc
|
||||
git
|
||||
llvm
|
||||
protobuf
|
||||
rustup-init
|
||||
libX11
|
||||
alsa-lib
|
||||
)
|
||||
$maysudo "$pkg" install "${deps[@]}"
|
||||
finalize
|
||||
exit 0
|
||||
fi
|
||||
10
script/generate-action-metadata
Executable file
10
script/generate-action-metadata
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "Generating action metadata..."
|
||||
cargo run -p zed -- --dump-all-actions > crates/docs_preprocessor/actions.json
|
||||
|
||||
echo "Generated crates/docs_preprocessor/actions.json with $(grep -c '"name":' crates/docs_preprocessor/actions.json) actions"
|
||||
56
script/generate-licenses
Executable file
56
script/generate-licenses
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CARGO_ABOUT_VERSION="0.8.2"
|
||||
OUTPUT_FILE="${1:-$(pwd)/assets/licenses.md}"
|
||||
TEMPLATE_FILE="script/licenses/template.md.hbs"
|
||||
|
||||
fail_on_stderr() {
|
||||
local tmpfile=$(mktemp)
|
||||
"$@" 2> >(tee "$tmpfile" >&2)
|
||||
local rc=$?
|
||||
[ -s "$tmpfile" ] && rc=1
|
||||
rm "$tmpfile"
|
||||
return $rc
|
||||
}
|
||||
|
||||
echo -n "" >"$OUTPUT_FILE"
|
||||
|
||||
{
|
||||
echo -e "# ###### THEME LICENSES ######\n"
|
||||
cat assets/themes/LICENSES
|
||||
|
||||
echo -e "\n# ###### ICON LICENSES ######\n"
|
||||
cat assets/icons/LICENSES
|
||||
|
||||
echo -e "\n# ###### CODE LICENSES ######\n"
|
||||
} >>"$OUTPUT_FILE"
|
||||
|
||||
if ! cargo about --version | grep "cargo-about $CARGO_ABOUT_VERSION" &>/dev/null; then
|
||||
echo "Installing cargo-about@$CARGO_ABOUT_VERSION..."
|
||||
cargo install "cargo-about@$CARGO_ABOUT_VERSION"
|
||||
else
|
||||
echo "cargo-about@$CARGO_ABOUT_VERSION is already installed."
|
||||
fi
|
||||
|
||||
echo "Generating cargo licenses"
|
||||
if [ -z "${ALLOW_MISSING_LICENSES-}" ]; then FAIL_FLAG=--fail; else FAIL_FLAG=""; fi
|
||||
if [ -z "${ALLOW_MISSING_LICENSES-}" ]; then WRAPPER=fail_on_stderr; else WRAPPER=""; fi
|
||||
set -x
|
||||
$WRAPPER cargo about generate \
|
||||
$FAIL_FLAG \
|
||||
-c script/licenses/zed-licenses.toml \
|
||||
"$TEMPLATE_FILE" >>"$OUTPUT_FILE"
|
||||
set +x
|
||||
|
||||
sed -i.bak 's/"/"/g' "$OUTPUT_FILE"
|
||||
sed -i.bak 's/'/'\''/g' "$OUTPUT_FILE" # The ` '\'' ` thing ends the string, appends a single quote, and re-opens the string
|
||||
sed -i.bak 's/=/=/g' "$OUTPUT_FILE"
|
||||
sed -i.bak 's/`/`/g' "$OUTPUT_FILE"
|
||||
sed -i.bak 's/</</g' "$OUTPUT_FILE"
|
||||
sed -i.bak 's/>/>/g' "$OUTPUT_FILE"
|
||||
|
||||
rm -rf "${OUTPUT_FILE}.bak"
|
||||
|
||||
echo "generate-licenses completed. See $OUTPUT_FILE"
|
||||
26
script/generate-licenses-csv
Executable file
26
script/generate-licenses-csv
Executable file
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CARGO_ABOUT_VERSION="0.8.2"
|
||||
OUTPUT_FILE="${1:-$(pwd)/assets/licenses.csv}"
|
||||
TEMPLATE_FILE="script/licenses/template.csv.hbs"
|
||||
|
||||
if ! cargo about --version | grep "cargo-about $CARGO_ABOUT_VERSION" 2>&1 > /dev/null; then
|
||||
echo "Installing cargo-about@$CARGO_ABOUT_VERSION..."
|
||||
cargo install "cargo-about@$CARGO_ABOUT_VERSION"
|
||||
else
|
||||
echo "cargo-about@$CARGO_ABOUT_VERSION is already installed."
|
||||
fi
|
||||
|
||||
echo "Generating cargo licenses"
|
||||
set -x
|
||||
cargo about generate \
|
||||
--fail \
|
||||
-c script/licenses/zed-licenses.toml \
|
||||
$TEMPLATE_FILE \
|
||||
| awk 'NR==1{print;next} NF{print | "sort"}' \
|
||||
> "$OUTPUT_FILE"
|
||||
set +x
|
||||
|
||||
echo "generate-licenses-csv completed. See $OUTPUT_FILE"
|
||||
56
script/generate-licenses.ps1
Normal file
56
script/generate-licenses.ps1
Normal file
@@ -0,0 +1,56 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$PSNativeCommandUseErrorActionPreference = $true
|
||||
|
||||
$CARGO_ABOUT_VERSION="0.8.2"
|
||||
$outputFile=$args[0] ? $args[0] : "$(Get-Location)/assets/licenses.md"
|
||||
$templateFile="script/licenses/template.md.hbs"
|
||||
|
||||
New-Item -Path "$outputFile" -ItemType File -Value "" -Force
|
||||
|
||||
@(
|
||||
"# ###### THEME LICENSES ######\n"
|
||||
Get-Content assets/themes/LICENSES
|
||||
"\n# ###### ICON LICENSES ######\n"
|
||||
Get-Content assets/icons/LICENSES
|
||||
"\n# ###### CODE LICENSES ######\n"
|
||||
) | Add-Content -Path $outputFile
|
||||
|
||||
$needsInstall = $false
|
||||
try {
|
||||
$versionOutput = cargo about --version
|
||||
if (-not ($versionOutput -match "cargo-about $CARGO_ABOUT_VERSION")) {
|
||||
$needsInstall = $true
|
||||
} else {
|
||||
Write-Host "cargo-about@$CARGO_ABOUT_VERSION is already installed"
|
||||
}
|
||||
} catch {
|
||||
$needsInstall = $true
|
||||
}
|
||||
|
||||
if ($needsInstall) {
|
||||
Write-Host "Installing cargo-about@$CARGO_ABOUT_VERSION..."
|
||||
cargo install "cargo-about@$CARGO_ABOUT_VERSION"
|
||||
}
|
||||
|
||||
Write-Host "Generating cargo licenses"
|
||||
|
||||
$failFlag = $env:ALLOW_MISSING_LICENSES ? "--fail" : ""
|
||||
$args = @('about', 'generate', $failFlag, '-c', 'script/licenses/zed-licenses.toml', $templateFile, '-o', $outputFile) | Where-Object { $_ }
|
||||
cargo @args
|
||||
|
||||
Write-Host "Applying replacements"
|
||||
$replacements = @{
|
||||
'"' = '"'
|
||||
''' = "'"
|
||||
'=' = '='
|
||||
'`' = '`'
|
||||
'<' = '<'
|
||||
'>' = '>'
|
||||
}
|
||||
$content = Get-Content $outputFile
|
||||
foreach ($find in $replacements.keys) {
|
||||
$content = $content -replace $find, $replacements[$find]
|
||||
}
|
||||
$content | Set-Content $outputFile
|
||||
|
||||
Write-Host "generate-licenses completed. See $outputFile"
|
||||
10
script/generate-terms-rtf
Executable file
10
script/generate-terms-rtf
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
if ! command -v pandoc &> /dev/null
|
||||
then
|
||||
brew install pandoc # Install pandoc using Homebrew
|
||||
fi
|
||||
|
||||
pandoc ./legal/terms.md -f markdown-smart -t rtf -o ./script/terms/terms.rtf --standalone
|
||||
17
script/get-crate-version
Executable file
17
script/get-crate-version
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eu
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "Usage: $0 <crate_name>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CRATE_NAME=$1
|
||||
|
||||
cargo metadata \
|
||||
--no-deps \
|
||||
--format-version=1 \
|
||||
| jq \
|
||||
--raw-output \
|
||||
".packages[] | select(.name == \"${CRATE_NAME}\") | .version"
|
||||
16
script/get-crate-version.ps1
Normal file
16
script/get-crate-version.ps1
Normal file
@@ -0,0 +1,16 @@
|
||||
if ($args.Length -ne 1) {
|
||||
Write-Error "Usage: $($MyInvocation.MyCommand.Name) <crate_name>"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$crateName = $args[0]
|
||||
|
||||
$metadata = cargo metadata --no-deps --format-version=1 | ConvertFrom-Json
|
||||
|
||||
$package = $metadata.packages | Where-Object { $_.name -eq $crateName }
|
||||
if ($package) {
|
||||
$package.version
|
||||
}
|
||||
else {
|
||||
Write-Error "Crate '$crateName' not found."
|
||||
}
|
||||
67
script/get-pull-requests-since
Executable file
67
script/get-pull-requests-since
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node --redirect-warnings=/dev/null
|
||||
|
||||
const { execFileSync } = require("child_process");
|
||||
let { GITHUB_ACCESS_TOKEN } = process.env;
|
||||
const PR_REGEX = /#\d+/; // Ex: matches on #4241
|
||||
const FIXES_REGEX = /(fixes|closes|completes) (.+[/#]\d+.*)$/im;
|
||||
|
||||
main();
|
||||
|
||||
async function main() {
|
||||
if (!GITHUB_ACCESS_TOKEN) {
|
||||
try {
|
||||
GITHUB_ACCESS_TOKEN = execFileSync("gh", ["auth", "token"]).toString();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
console.log("No GITHUB_ACCESS_TOKEN, and no `gh auth token`");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Use form of: YYYY-MM-DD - 2023-01-09
|
||||
const startDate = new Date(process.argv[2]);
|
||||
const today = new Date();
|
||||
|
||||
console.log(`Pull requests from ${startDate} to ${today}\n`);
|
||||
|
||||
let pullRequestNumbers = getPullRequestNumbers(startDate, today);
|
||||
|
||||
// Fetch the pull requests from the GitHub API.
|
||||
console.log("Merged pull requests:");
|
||||
for (const pullRequestNumber of pullRequestNumbers) {
|
||||
const webURL = `https://github.com/zed-industries/zed/pull/${pullRequestNumber}`;
|
||||
const apiURL = `https://api.github.com/repos/zed-industries/zed/pulls/${pullRequestNumber}`;
|
||||
|
||||
const response = await fetch(apiURL, {
|
||||
headers: {
|
||||
Authorization: `token ${GITHUB_ACCESS_TOKEN}`,
|
||||
},
|
||||
});
|
||||
|
||||
const pullRequest = await response.json();
|
||||
console.log("*", pullRequest.title);
|
||||
console.log(" PR URL: ", webURL);
|
||||
console.log(" Merged: ", pullRequest.merged_at);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
function getPullRequestNumbers(startDate, endDate) {
|
||||
const sinceDate = startDate.toISOString();
|
||||
const untilDate = endDate.toISOString();
|
||||
|
||||
const pullRequestNumbers = execFileSync(
|
||||
"git",
|
||||
["log", `--since=${sinceDate}`, `--until=${untilDate}`, "--oneline"],
|
||||
{ encoding: "utf8" },
|
||||
)
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
const match = line.match(/#(\d+)/);
|
||||
return match ? match[1] : null;
|
||||
})
|
||||
.filter((line) => line);
|
||||
|
||||
return pullRequestNumbers;
|
||||
}
|
||||
125
script/get-release-notes-since
Executable file
125
script/get-release-notes-since
Executable file
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env node --redirect-warnings=/dev/null
|
||||
|
||||
const { execFileSync } = require("child_process");
|
||||
const { GITHUB_ACCESS_TOKEN } = process.env;
|
||||
|
||||
main();
|
||||
|
||||
async function main() {
|
||||
const startDate = new Date(process.argv[2]);
|
||||
const today = new Date();
|
||||
|
||||
console.log(`Release notes from ${startDate} to ${today}\n`);
|
||||
|
||||
const releases = await getReleases(startDate, today);
|
||||
const previewReleases = releases.filter((release) =>
|
||||
release.tagName.includes("-pre"),
|
||||
);
|
||||
|
||||
const stableReleases = releases.filter(
|
||||
(release) => !release.tagName.includes("-pre"),
|
||||
);
|
||||
|
||||
// Filter out all preview release, as all of those changes have made it to the stable release, except for the latest preview release
|
||||
const aggregatedReleases = stableReleases
|
||||
.concat(previewReleases[0])
|
||||
.reverse();
|
||||
|
||||
const aggregatedReleaseTitles = aggregatedReleases
|
||||
.map((release) => release.name)
|
||||
.join(", ");
|
||||
|
||||
console.log();
|
||||
console.log(`Release titles: ${aggregatedReleaseTitles}`);
|
||||
|
||||
console.log("Release notes:");
|
||||
console.log();
|
||||
|
||||
for (const release of aggregatedReleases) {
|
||||
const publishedDate = release.publishedAt.split("T")[0];
|
||||
console.log(`${release.name}: ${publishedDate}`);
|
||||
console.log();
|
||||
console.log(release.description);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
async function getReleases(startDate, endDate) {
|
||||
const query = `
|
||||
query ($owner: String!, $repo: String!, $cursor: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
releases(first: 100, orderBy: {field: CREATED_AT, direction: DESC}, after: $cursor) {
|
||||
nodes {
|
||||
tagName
|
||||
name
|
||||
createdAt
|
||||
publishedAt
|
||||
description
|
||||
url
|
||||
author {
|
||||
login
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
let allReleases = [];
|
||||
let hasNextPage = true;
|
||||
let cursor = null;
|
||||
|
||||
while (hasNextPage) {
|
||||
const response = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${GITHUB_ACCESS_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables: { owner: "zed-industries", repo: "zed", cursor },
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.errors) {
|
||||
throw new Error(`GraphQL error: ${JSON.stringify(data.errors)}`);
|
||||
}
|
||||
|
||||
if (!data.data || !data.data.repository || !data.data.repository.releases) {
|
||||
throw new Error(`Unexpected response structure: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
const releases = data.data.repository.releases.nodes;
|
||||
allReleases = allReleases.concat(releases);
|
||||
|
||||
hasNextPage = data.data.repository.releases.pageInfo.hasNextPage;
|
||||
cursor = data.data.repository.releases.pageInfo.endCursor;
|
||||
|
||||
lastReleaseOnPage = releases[releases.length - 1];
|
||||
|
||||
if (
|
||||
releases.length > 0 &&
|
||||
new Date(lastReleaseOnPage.createdAt) < startDate
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const filteredReleases = allReleases.filter((release) => {
|
||||
const releaseDate = new Date(release.createdAt);
|
||||
return releaseDate >= startDate && releaseDate <= endDate;
|
||||
});
|
||||
|
||||
return filteredReleases;
|
||||
}
|
||||
21
script/get-released-version
Executable file
21
script/get-released-version
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
channel="$1"
|
||||
|
||||
query=""
|
||||
case $channel in
|
||||
stable)
|
||||
;;
|
||||
preview)
|
||||
query="&preview=1"
|
||||
;;
|
||||
nightly)
|
||||
query="&nightly=1"
|
||||
;;
|
||||
*)
|
||||
echo "this must be run on either of stable|preview|nightly release branches" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
curl -s "https://cloud.zed.dev/releases/$channel/latest/asset?asset=zed&os=macos&arch=aarch64" | jq -r .version
|
||||
421
script/github-assign-contributor-issue.py
Normal file
421
script/github-assign-contributor-issue.py
Normal file
@@ -0,0 +1,421 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Assign a labeled contributor issue to the least-busy interested contributor.
|
||||
|
||||
When an issue has both a `.contrib/good *` label and an `area:` label, this
|
||||
script:
|
||||
1. Fetches Tally form responses to find contributors interested in the issue's areas
|
||||
2. Queries GitHub for each candidate's current open issue assignment count
|
||||
3. Assigns the issue to the least-busy candidate (random tiebreak)
|
||||
4. Adds the issue to a GitHub project board with "Assign" status
|
||||
5. Notifies the assignee via Slack DM and posts to an activity channel
|
||||
|
||||
Errors and notable conditions (no candidates found, API failures) are reported
|
||||
to the Slack activity channel before the script exits.
|
||||
|
||||
Requires:
|
||||
requests (pip install requests)
|
||||
|
||||
Usage:
|
||||
python github-assign-contributor-issue.py <issue_number>
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
|
||||
import requests
|
||||
|
||||
GITHUB_API = "https://api.github.com"
|
||||
TALLY_API = "https://api.tally.so"
|
||||
SLACK_API = "https://slack.com/api"
|
||||
|
||||
REPO_OWNER = "zed-industries"
|
||||
REPO_NAME = "zed"
|
||||
PROJECT_NUMBER = 83
|
||||
SLACK_ACTIVITY_CHANNEL_ID = "C0B0JCE8GDC"
|
||||
|
||||
|
||||
def eligible_areas(issue):
|
||||
"""Returns the list of area names if the issue is eligible for assignment, or None."""
|
||||
labels = [label["name"] for label in issue["labels"]]
|
||||
assignees = [a["login"] for a in issue["assignees"]]
|
||||
|
||||
contrib_labels = [name for name in labels if name.startswith(".contrib/good ")]
|
||||
area_labels = [name for name in labels if name.startswith("area:")]
|
||||
|
||||
if not contrib_labels or not area_labels:
|
||||
print("Issue needs both a .contrib/good * label and an area: label, skipping")
|
||||
return None
|
||||
|
||||
if assignees:
|
||||
print(f"Issue is already assigned to {assignees}, skipping")
|
||||
return None
|
||||
|
||||
areas = [label.removeprefix("area:") for label in area_labels]
|
||||
print(f"Areas: {areas}")
|
||||
return areas
|
||||
|
||||
|
||||
# --- Tally ---
|
||||
|
||||
|
||||
def fetch_tally_contributors(api_key, form_id):
|
||||
"""Fetch all completed submissions from a Tally form.
|
||||
|
||||
Deduplicates by GitHub username, keeping the latest submission.
|
||||
"""
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
contributors = {}
|
||||
page = 1
|
||||
|
||||
while True:
|
||||
response = requests.get(
|
||||
f"{TALLY_API}/forms/{form_id}/submissions",
|
||||
headers=headers,
|
||||
params={"page": page, "limit": 500, "filter": "completed"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
field_titles = {}
|
||||
for question in data.get("questions", []):
|
||||
for field in question.get("fields", []):
|
||||
field_titles[field["uuid"]] = field.get("title", "")
|
||||
|
||||
questions = {q["id"]: q for q in data.get("questions", [])}
|
||||
|
||||
for submission in data.get("submissions", []):
|
||||
record = parse_submission(submission, questions, field_titles)
|
||||
if record:
|
||||
contributors[record["github_username"].lower()] = record
|
||||
|
||||
if not data.get("hasMore", False):
|
||||
break
|
||||
page += 1
|
||||
|
||||
return list(contributors.values())
|
||||
|
||||
|
||||
def parse_submission(submission, questions, field_titles):
|
||||
"""Parse a single Tally submission into a contributor record.
|
||||
|
||||
Returns a dict with github_username, email (optional), and areas,
|
||||
or None if the submission is incomplete.
|
||||
"""
|
||||
github_username = None
|
||||
email = None
|
||||
areas = []
|
||||
|
||||
for response in submission.get("responses", []):
|
||||
try:
|
||||
question_title = questions[response["questionId"]]["title"].lower()
|
||||
answer = response["answer"]
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
try:
|
||||
if "github" in question_title:
|
||||
github_username = str(answer).strip().lstrip("@")
|
||||
elif "email" in question_title:
|
||||
email = str(answer).strip().lower()
|
||||
elif "area" in question_title:
|
||||
for item in answer if isinstance(answer, list) else [answer]:
|
||||
area = field_titles.get(item, item).strip()
|
||||
if area:
|
||||
areas.append(area)
|
||||
except (TypeError, AttributeError):
|
||||
continue
|
||||
|
||||
if not github_username or not areas:
|
||||
return None
|
||||
|
||||
record = {"github_username": github_username, "areas": areas}
|
||||
if email:
|
||||
record["email"] = email
|
||||
return record
|
||||
|
||||
|
||||
def find_candidates(contributors, area_names):
|
||||
"""Find contributors interested in any of the given areas (case-insensitive)."""
|
||||
target = {name.lower() for name in area_names}
|
||||
return [c for c in contributors if any(a.lower() in target for a in c["areas"])]
|
||||
|
||||
|
||||
def pick_least_busy(github_headers, candidates):
|
||||
"""Pick the candidate with the fewest open assignments (random tiebreak)."""
|
||||
usernames = [c["github_username"] for c in candidates]
|
||||
loads = count_open_assignments(github_headers, usernames)
|
||||
for username, count in loads.items():
|
||||
print(f" {username}: {count} open assignments")
|
||||
|
||||
min_load = min(loads.values())
|
||||
least_busy = [c for c in candidates if loads[c["github_username"]] == min_load]
|
||||
chosen = random.choice(least_busy)
|
||||
print(
|
||||
f"Selected: {chosen['github_username']} (load: {min_load}, {len(least_busy)} tied)"
|
||||
)
|
||||
return chosen
|
||||
|
||||
|
||||
# --- GitHub ---
|
||||
|
||||
|
||||
def fetch_issue(headers, issue_number):
|
||||
"""Fetch issue details from the GitHub API."""
|
||||
response = requests.get(
|
||||
f"{GITHUB_API}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}",
|
||||
headers=headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def count_open_assignments(headers, usernames):
|
||||
"""Count open issues assigned to each user in a single GraphQL request."""
|
||||
aliases = [
|
||||
f'u{i}: search(query: "repo:{REPO_OWNER}/{REPO_NAME} is:issue is:open assignee:{name}", type: ISSUE) {{ issueCount }}'
|
||||
for i, name in enumerate(usernames)
|
||||
]
|
||||
query = "query {\n" + "\n".join(aliases) + "\n}"
|
||||
data = execute_graphql(headers, query, {})
|
||||
return {name: data[f"u{i}"]["issueCount"] for i, name in enumerate(usernames)}
|
||||
|
||||
|
||||
def assign_issue(headers, issue_number, username):
|
||||
"""Assign a GitHub issue to a user."""
|
||||
response = requests.post(
|
||||
f"{GITHUB_API}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/assignees",
|
||||
headers=headers,
|
||||
json={"assignees": [username]},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
|
||||
def execute_graphql(headers, query, variables):
|
||||
"""Execute a GitHub GraphQL query. Raises on HTTP or GraphQL errors."""
|
||||
response = requests.post(
|
||||
f"{GITHUB_API}/graphql",
|
||||
headers=headers,
|
||||
json={"query": query, "variables": variables},
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if "errors" in result:
|
||||
raise RuntimeError(f"GraphQL error: {result['errors']}")
|
||||
return result["data"]
|
||||
|
||||
|
||||
def fetch_project(headers, project_number):
|
||||
"""Fetch a GitHub project board's metadata including fields and status options."""
|
||||
data = execute_graphql(
|
||||
headers,
|
||||
"""
|
||||
query($owner: String!, $number: Int!) {
|
||||
organization(login: $owner) {
|
||||
projectV2(number: $number) {
|
||||
id
|
||||
fields(first: 50) {
|
||||
nodes {
|
||||
... on ProjectV2SingleSelectField {
|
||||
id
|
||||
name
|
||||
options { id name }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
{"owner": REPO_OWNER, "number": project_number},
|
||||
)
|
||||
return data["organization"]["projectV2"]
|
||||
|
||||
|
||||
def add_issue_to_project(headers, project_id, issue_node_id):
|
||||
"""Add an issue to a GitHub project board. Returns the project item ID."""
|
||||
data = execute_graphql(
|
||||
headers,
|
||||
"""
|
||||
mutation($projectId: ID!, $contentId: ID!) {
|
||||
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
|
||||
item { id }
|
||||
}
|
||||
}
|
||||
""",
|
||||
{"projectId": project_id, "contentId": issue_node_id},
|
||||
)
|
||||
item_id = data["addProjectV2ItemById"]["item"]["id"]
|
||||
print(f"Added issue to project (item: {item_id})")
|
||||
return item_id
|
||||
|
||||
|
||||
def set_project_item_status(headers, project, item_id, status_name):
|
||||
"""Set the Status field on a project item. Hard-fails if the status option is missing."""
|
||||
status_field_id = None
|
||||
option_id = None
|
||||
for field in project["fields"]["nodes"]:
|
||||
if field.get("name") == "Status":
|
||||
status_field_id = field["id"]
|
||||
for option in field.get("options", []):
|
||||
if option["name"] == status_name:
|
||||
option_id = option["id"]
|
||||
break
|
||||
break
|
||||
|
||||
if not status_field_id or not option_id:
|
||||
available = [f.get("name") for f in project["fields"]["nodes"] if f.get("name")]
|
||||
raise RuntimeError(
|
||||
f"Could not find Status field with '{status_name}' option. "
|
||||
f"Fields found: {available}"
|
||||
)
|
||||
|
||||
execute_graphql(
|
||||
headers,
|
||||
"""
|
||||
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
||||
updateProjectV2ItemFieldValue(input: {
|
||||
projectId: $projectId
|
||||
itemId: $itemId
|
||||
fieldId: $fieldId
|
||||
value: { singleSelectOptionId: $optionId }
|
||||
}) {
|
||||
projectV2Item { id }
|
||||
}
|
||||
}
|
||||
""",
|
||||
{
|
||||
"projectId": project["id"],
|
||||
"itemId": item_id,
|
||||
"fieldId": status_field_id,
|
||||
"optionId": option_id,
|
||||
},
|
||||
)
|
||||
print(f"Set project status to '{status_name}'")
|
||||
|
||||
|
||||
# --- Slack ---
|
||||
|
||||
|
||||
def slack_post_message(headers, recipient, text):
|
||||
"""Post a message to a Slack channel or user DM."""
|
||||
response = requests.post(
|
||||
f"{SLACK_API}/chat.postMessage",
|
||||
headers=headers,
|
||||
json={"channel": recipient, "text": text},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if not data["ok"]:
|
||||
raise RuntimeError(f"Slack API error: {data['error']}")
|
||||
|
||||
|
||||
def find_slack_user_id(headers, email):
|
||||
"""Look up a Slack user ID by email. Returns None if not found."""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{SLACK_API}/users.lookupByEmail",
|
||||
headers=headers,
|
||||
params={"email": email},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["user"]["id"]
|
||||
except (requests.RequestException, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def post_to_activity(slack_headers, message):
|
||||
"""Best-effort post to the Slack activity channel."""
|
||||
try:
|
||||
slack_post_message(slack_headers, SLACK_ACTIVITY_CHANNEL_ID, message)
|
||||
except Exception as exc:
|
||||
print(f"Failed to post to Slack activity channel: {exc}")
|
||||
|
||||
|
||||
def notify_assignment(slack_headers, chosen, issue):
|
||||
"""DM the chosen contributor and post to the activity channel."""
|
||||
issue_number = issue["number"]
|
||||
issue_title = issue["title"]
|
||||
issue_url = issue["html_url"]
|
||||
chosen_username = chosen["github_username"]
|
||||
|
||||
slack_user_id = find_slack_user_id(slack_headers, chosen.get("email"))
|
||||
|
||||
if slack_user_id:
|
||||
slack_post_message(
|
||||
slack_headers,
|
||||
slack_user_id,
|
||||
f"\U0001f44b You've been assigned to <{issue_url}|#{issue_number}: {issue_title}>! "
|
||||
f"This issue matches your areas of interest. "
|
||||
f"Let us know if you have any questions.",
|
||||
)
|
||||
|
||||
activity_message = (
|
||||
f"\U0001f4cb <{issue_url}|#{issue_number}: {issue_title}> "
|
||||
f"assigned to *{chosen_username}*"
|
||||
)
|
||||
if slack_user_id:
|
||||
activity_message += f" (<@{slack_user_id}>)"
|
||||
post_to_activity(slack_headers, activity_message)
|
||||
|
||||
|
||||
# --- Main ---
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
issue_number = sys.argv[1]
|
||||
|
||||
github_token = os.environ["GITHUB_TOKEN"]
|
||||
tally_api_key = os.environ["TALLY_API_KEY"]
|
||||
tally_form_id = os.environ["TALLY_FORM_ID"]
|
||||
slack_bot_token = os.environ["SLACK_CONTRIBUTOR_ROUTING_BOT_TOKEN"]
|
||||
|
||||
github_headers = {
|
||||
"Authorization": f"Bearer {github_token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
slack_headers = {
|
||||
"Authorization": f"Bearer {slack_bot_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
issue = fetch_issue(github_headers, issue_number)
|
||||
if not (areas := eligible_areas(issue)):
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
contributors = fetch_tally_contributors(tally_api_key, tally_form_id)
|
||||
print(f"Found {len(contributors)} contributors in Tally")
|
||||
|
||||
candidates = find_candidates(contributors, areas)
|
||||
if not candidates:
|
||||
post_to_activity(
|
||||
slack_headers,
|
||||
f"\u26a0\ufe0f No contributors found for {', '.join(areas)} \u2014 "
|
||||
f"<{issue['html_url']}|#{issue_number}: {issue['title']}>",
|
||||
)
|
||||
print(f"No contributors interested in areas: {areas}")
|
||||
sys.exit(0)
|
||||
|
||||
chosen = pick_least_busy(github_headers, candidates)
|
||||
|
||||
assign_issue(github_headers, issue_number, chosen["github_username"])
|
||||
print(f"Assigned #{issue_number} to {chosen['github_username']}")
|
||||
|
||||
project = fetch_project(github_headers, PROJECT_NUMBER)
|
||||
item_id = add_issue_to_project(github_headers, project["id"], issue["node_id"])
|
||||
set_project_item_status(github_headers, project, item_id, "Assigned")
|
||||
|
||||
notify_assignment(slack_headers, chosen, issue)
|
||||
|
||||
except Exception as exc:
|
||||
post_to_activity(
|
||||
slack_headers,
|
||||
f"\u274c Failed to assign contributor for "
|
||||
f"<{issue['html_url']}|#{issue_number}: {issue['title']}>: {exc}",
|
||||
)
|
||||
raise
|
||||
562
script/github-check-new-issue-for-duplicates.py
Normal file
562
script/github-check-new-issue-for-duplicates.py
Normal file
@@ -0,0 +1,562 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Comment on newly opened issues that might be duplicates of an existing issue.
|
||||
|
||||
This script is run by a GitHub Actions workflow when a new bug or crash report
|
||||
is opened. It:
|
||||
1. Checks eligibility (must be bug/crash type, non-staff author)
|
||||
2. Detects relevant areas using Claude + the area label taxonomy
|
||||
3. Parses known "duplicate magnets" from tracking issue #46355
|
||||
4. Searches for similar recent issues by title keywords, area labels, and error patterns
|
||||
5. Asks Claude to analyze potential duplicates (magnets + search results)
|
||||
6. Posts a comment on the issue if high-confidence duplicates are found
|
||||
|
||||
Requires:
|
||||
requests (pip install requests)
|
||||
|
||||
Usage:
|
||||
python github-check-new-issue-for-duplicates.py <issue_number>
|
||||
|
||||
Environment variables:
|
||||
GITHUB_TOKEN - GitHub token (org members: read, issues: read & write)
|
||||
ANTHROPIC_API_KEY - Anthropic API key for Claude
|
||||
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import requests
|
||||
|
||||
GITHUB_API = "https://api.github.com"
|
||||
REPO_OWNER = "zed-industries"
|
||||
REPO_NAME = "zed"
|
||||
TRACKING_ISSUE_NUMBER = 46355
|
||||
STAFF_TEAM_SLUG = "staff"
|
||||
|
||||
# area prefixes to collapse in taxonomy (show summary instead of all sub-labels)
|
||||
PREFIXES_TO_COLLAPSE = ["languages", "parity", "tooling"]
|
||||
|
||||
# stopwords to filter from title keyword searches (short words handled by len > 2 filter)
|
||||
STOPWORDS = {
|
||||
"after", "all", "also", "and", "any", "but", "can't", "does", "doesn't",
|
||||
"don't", "for", "from", "have", "just", "not", "only", "some", "that",
|
||||
"the", "this", "when", "while", "with", "won't", "work", "working", "zed",
|
||||
}
|
||||
|
||||
|
||||
def log(message):
|
||||
"""Print to stderr so it doesn't interfere with JSON output on stdout."""
|
||||
print(message, file=sys.stderr)
|
||||
|
||||
|
||||
def github_api_get(path, params=None):
|
||||
"""Fetch JSON from the GitHub API. Raises on non-2xx status."""
|
||||
url = f"{GITHUB_API}/{path.lstrip('/')}"
|
||||
response = requests.get(url, headers=GITHUB_HEADERS, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def github_search_issues(query, per_page=15):
|
||||
"""Search issues, returning most recently created first."""
|
||||
params = {"q": query, "sort": "created", "order": "desc", "per_page": per_page}
|
||||
return github_api_get("/search/issues", params).get("items", [])
|
||||
|
||||
|
||||
def check_team_membership(org, team_slug, username):
|
||||
"""Check if user is an active member of a team."""
|
||||
try:
|
||||
data = github_api_get(f"/orgs/{org}/teams/{team_slug}/memberships/{username}")
|
||||
return data.get("state") == "active"
|
||||
except requests.HTTPError as e:
|
||||
if e.response.status_code == 404:
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
def post_comment(issue_number: int, body):
|
||||
url = f"{GITHUB_API.rstrip('/')}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments"
|
||||
response = requests.post(url, headers=GITHUB_HEADERS, json={"body": body})
|
||||
response.raise_for_status()
|
||||
log(f" Posted comment on #{issue_number}")
|
||||
|
||||
|
||||
def build_duplicate_comment(matches):
|
||||
"""Build the comment body for potential duplicates."""
|
||||
match_list = "\n".join(f"- #{m['number']}" for m in matches)
|
||||
explanations = "\n\n".join(
|
||||
f"**#{m['number']}:** {m['explanation']}\n\n**Shared root cause:** {m['shared_root_cause']}"
|
||||
if m.get('shared_root_cause')
|
||||
else f"**#{m['number']}:** {m['explanation']}"
|
||||
for m in matches
|
||||
)
|
||||
|
||||
return f"""This issue appears to be a duplicate of:
|
||||
|
||||
{match_list}
|
||||
|
||||
**If this is indeed a duplicate:**
|
||||
Please close this issue and subscribe to the linked issue for updates (select "Close as not planned" → "Duplicate")
|
||||
|
||||
**If this is a different issue:**
|
||||
No action needed. A maintainer will review this shortly.
|
||||
|
||||
<details>
|
||||
<summary>Why were these issues selected?</summary>
|
||||
|
||||
{explanations}
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
<sub>This is an automated analysis and might be incorrect.</sub>"""
|
||||
|
||||
|
||||
def call_claude(api_key, system, user_content, max_tokens=1024):
|
||||
"""Send a message to Claude and return the text response. Raises on non-2xx status."""
|
||||
response = requests.post(
|
||||
"https://api.anthropic.com/v1/messages",
|
||||
headers={
|
||||
"x-api-key": api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": 0.0,
|
||||
"system": system,
|
||||
"messages": [{"role": "user", "content": user_content}],
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
usage = data.get("usage", {})
|
||||
log(f" Token usage - Input: {usage.get('input_tokens', 'N/A')}, Output: {usage.get('output_tokens', 'N/A')}")
|
||||
|
||||
content = data.get("content", [])
|
||||
if content and content[0].get("type") == "text":
|
||||
return content[0].get("text") or ""
|
||||
return ""
|
||||
|
||||
|
||||
def fetch_issue(issue_number: int):
|
||||
"""Fetch issue from GitHub and return as a dict."""
|
||||
log(f"Fetching issue #{issue_number}")
|
||||
|
||||
issue_data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}")
|
||||
issue = {
|
||||
"number": issue_number,
|
||||
"title": issue_data["title"],
|
||||
"body": issue_data.get("body") or "",
|
||||
"author": (issue_data.get("user") or {}).get("login") or "",
|
||||
"type": (issue_data.get("type") or {}).get("name"),
|
||||
}
|
||||
|
||||
log(f" Title: {issue['title']}\n Type: {issue['type']}\n Author: {issue['author']}")
|
||||
return issue
|
||||
|
||||
|
||||
def should_skip(issue):
|
||||
"""Check if issue should be skipped in duplicate detection process."""
|
||||
if issue["type"] not in ["Bug", "Crash"]:
|
||||
log(f" Skipping: issue type '{issue['type']}' is not a bug/crash report")
|
||||
return True
|
||||
|
||||
if issue["author"] and check_team_membership(REPO_OWNER, STAFF_TEAM_SLUG, issue["author"]):
|
||||
log(f" Skipping: author '{issue['author']}' is a {STAFF_TEAM_SLUG} member")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def fetch_area_labels():
|
||||
"""Fetch area:* labels from the repository. Returns list of {name, description} dicts."""
|
||||
log("Fetching area labels")
|
||||
|
||||
labels = []
|
||||
page = 1
|
||||
while page_labels := github_api_get(
|
||||
f"/repos/{REPO_OWNER}/{REPO_NAME}/labels",
|
||||
params={"per_page": 100, "page": page},
|
||||
):
|
||||
labels.extend(page_labels)
|
||||
page += 1
|
||||
|
||||
# label["name"][5:] removes the "area:" prefix
|
||||
area_labels = [
|
||||
{"name": label["name"][5:], "description": label.get("description") or ""}
|
||||
for label in labels
|
||||
if label["name"].startswith("area:")
|
||||
]
|
||||
|
||||
log(f" Found {len(area_labels)} area labels")
|
||||
return area_labels
|
||||
|
||||
|
||||
def format_taxonomy_for_claude(area_labels):
|
||||
"""Format area labels into a string for Claude, collapsing certain prefixes."""
|
||||
lines = set()
|
||||
|
||||
for area in area_labels:
|
||||
name = area["name"]
|
||||
collapsible_prefix = next(
|
||||
(p for p in PREFIXES_TO_COLLAPSE if name.startswith(f"{p}/")), None)
|
||||
|
||||
if collapsible_prefix:
|
||||
lines.add(f"- {collapsible_prefix}/* (multiple specific sub-labels exist)")
|
||||
else:
|
||||
desc = area["description"]
|
||||
lines.add(f"- {name}: {desc}" if desc else f"- {name}")
|
||||
|
||||
return "\n".join(sorted(lines))
|
||||
|
||||
|
||||
def detect_areas(anthropic_key, issue, taxonomy):
|
||||
"""Use Claude to detect relevant areas for the issue."""
|
||||
log("Detecting areas with Claude")
|
||||
|
||||
system_prompt = """You analyze GitHub issues to identify which area labels apply.
|
||||
|
||||
Given an issue and a taxonomy of areas, output ONLY a comma-separated list of matching area names.
|
||||
- Output at most 3 areas, ranked by relevance
|
||||
- Use exact area names from the taxonomy
|
||||
- If no areas clearly match, output: none
|
||||
- For languages/*, tooling/*, or parity/*, use the specific sub-label (e.g., "languages/rust",
|
||||
tooling/eslint, parity/vscode)
|
||||
|
||||
Example outputs:
|
||||
- "editor, parity/vim"
|
||||
- "ai, ai/agent panel"
|
||||
- "none"
|
||||
"""
|
||||
|
||||
user_content = f"""## Area Taxonomy
|
||||
{taxonomy}
|
||||
|
||||
# Issue Title
|
||||
{issue['title']}
|
||||
|
||||
# Issue Body
|
||||
{issue['body'][:4000]}"""
|
||||
|
||||
response = call_claude(anthropic_key, system_prompt, user_content, max_tokens=100).strip()
|
||||
log(f" Detected areas: {response}")
|
||||
|
||||
if response.lower() == "none":
|
||||
return []
|
||||
return [area.strip() for area in response.split(",")]
|
||||
|
||||
|
||||
def parse_duplicate_magnets():
|
||||
"""Parse known duplicate magnets from tracking issue #46355.
|
||||
|
||||
Returns a list of magnets sorted by duplicate count (most duplicated first).
|
||||
Magnets only have number, areas, and dupe_count — use enrich_magnets() to fetch
|
||||
title and body_preview for the ones you need.
|
||||
"""
|
||||
log(f"Parsing duplicate magnets from #{TRACKING_ISSUE_NUMBER}")
|
||||
|
||||
issue_data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{TRACKING_ISSUE_NUMBER}")
|
||||
body = issue_data.get("body") or ""
|
||||
|
||||
# parse the issue body
|
||||
# format: ## area_name
|
||||
# - [N dupes] https://github.com/zed-industries/zed/issues/NUMBER
|
||||
magnets = {} # number -> {number, areas, dupe_count}
|
||||
current_area = None
|
||||
|
||||
for line in body.split("\n"):
|
||||
# check for area header
|
||||
if line.startswith("## "):
|
||||
current_area = line[3:].strip()
|
||||
continue
|
||||
|
||||
if not current_area or not line.startswith("-") or "/issues/" not in line:
|
||||
continue
|
||||
|
||||
# parse: - [N dupes] https://github.com/.../issues/NUMBER
|
||||
try:
|
||||
dupe_count = int(line.split("[")[1].split()[0])
|
||||
number = int(line.split("/issues/")[1].split()[0].rstrip(")"))
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
# skip "(unlabeled)": these magnets should match everything
|
||||
is_unlabeled = current_area == "(unlabeled)"
|
||||
|
||||
if number in magnets:
|
||||
if not is_unlabeled:
|
||||
magnets[number]["areas"].append(current_area)
|
||||
else:
|
||||
magnets[number] = {
|
||||
"number": number,
|
||||
"areas": [] if is_unlabeled else [current_area],
|
||||
"dupe_count": dupe_count,
|
||||
}
|
||||
|
||||
magnet_list = sorted(magnets.values(), key=lambda m: m["dupe_count"], reverse=True)
|
||||
log(f" Parsed {len(magnet_list)} duplicate magnets")
|
||||
return magnet_list
|
||||
|
||||
|
||||
def enrich_magnets(magnets):
|
||||
"""Fetch title and body_preview for magnets from the API."""
|
||||
log(f" Fetching details for {len(magnets)} magnets")
|
||||
for magnet in magnets:
|
||||
data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{magnet['number']}")
|
||||
magnet["title"] = data["title"]
|
||||
magnet["body_preview"] = (data.get("body") or "")[:1000]
|
||||
|
||||
|
||||
def areas_match(detected, magnet_area):
|
||||
"""Check if detected area matches magnet area. Matches broadly across hierarchy levels."""
|
||||
return (
|
||||
detected == magnet_area
|
||||
or magnet_area.startswith(f"{detected}/")
|
||||
or detected.startswith(f"{magnet_area}/")
|
||||
)
|
||||
|
||||
|
||||
def filter_magnets_by_areas(magnets, detected_areas):
|
||||
"""Filter magnets based on detected areas."""
|
||||
if not detected_areas:
|
||||
return magnets
|
||||
|
||||
detected_set = set(detected_areas)
|
||||
|
||||
def matches(magnet):
|
||||
# unlabeled magnets (empty areas) match everything
|
||||
if not magnet["areas"]:
|
||||
return True
|
||||
return any(
|
||||
areas_match(detected, magnet_area)
|
||||
for detected in detected_set
|
||||
for magnet_area in magnet["areas"]
|
||||
)
|
||||
|
||||
return list(filter(matches, magnets))
|
||||
|
||||
|
||||
def search_for_similar_issues(issue, detected_areas, max_searches=6):
|
||||
"""Search for similar issues that might be duplicates.
|
||||
|
||||
Searches by title keywords, area labels (last 60 days), and error patterns.
|
||||
max_searches caps the total number of queries to keep token usage and context size under control.
|
||||
"""
|
||||
log("Searching for similar issues")
|
||||
|
||||
sixty_days_ago = (datetime.now() - timedelta(days=60)).strftime("%Y-%m-%d")
|
||||
base_query = f"repo:{REPO_OWNER}/{REPO_NAME} is:issue is:open"
|
||||
seen_issues = {}
|
||||
queries = []
|
||||
|
||||
title_keywords = [word for word in issue["title"].split() if word.lower() not in STOPWORDS and len(word) > 2]
|
||||
|
||||
if title_keywords:
|
||||
keywords_query = " ".join(title_keywords)
|
||||
queries.append(("title_keywords", f"{base_query} {keywords_query}"))
|
||||
|
||||
for area in detected_areas:
|
||||
queries.append(("area_label", f'{base_query} label:"area:{area}" created:>{sixty_days_ago}'))
|
||||
|
||||
# error pattern search: capture 5–90 chars after keyword, colon optional
|
||||
error_pattern = r"(?i:\b(?:error|panicked|panic|failed)\b)\s*([^\n]{5,90})"
|
||||
match = re.search(error_pattern, issue["body"])
|
||||
if match:
|
||||
error_snippet = match.group(1).strip()
|
||||
queries.append(("error_pattern", f'{base_query} in:body "{error_snippet}"'))
|
||||
|
||||
for search_type, query in queries[:max_searches]:
|
||||
log(f" Search ({search_type}): {query}")
|
||||
try:
|
||||
results = github_search_issues(query, per_page=15)
|
||||
for item in results:
|
||||
number = item["number"]
|
||||
if number != issue["number"] and number not in seen_issues:
|
||||
body = item.get("body") or ""
|
||||
seen_issues[number] = {
|
||||
"number": number,
|
||||
"title": item["title"],
|
||||
"state": item.get("state", ""),
|
||||
"created_at": item.get("created_at", ""),
|
||||
"body_preview": body[:1000],
|
||||
"source": search_type,
|
||||
}
|
||||
except requests.RequestException as e:
|
||||
log(f" Search failed: {e}")
|
||||
|
||||
similar_issues = list(seen_issues.values())
|
||||
log(f" Found {len(similar_issues)} similar issues")
|
||||
return similar_issues
|
||||
|
||||
|
||||
def analyze_duplicates(anthropic_key, issue, magnets, search_results):
|
||||
"""Use Claude to analyze potential duplicates."""
|
||||
log("Analyzing duplicates with Claude")
|
||||
|
||||
top_magnets = magnets[:10]
|
||||
enrich_magnets(top_magnets)
|
||||
magnet_numbers = {m["number"] for m in top_magnets}
|
||||
|
||||
candidates = [
|
||||
{"number": m["number"], "title": m["title"], "body_preview": m["body_preview"], "source": "known_duplicate_magnet"}
|
||||
for m in top_magnets
|
||||
] + [
|
||||
{"number": r["number"], "title": r["title"], "body_preview": r["body_preview"], "source": "search_result"}
|
||||
for r in search_results[:10]
|
||||
if r["number"] not in magnet_numbers
|
||||
]
|
||||
|
||||
if not candidates:
|
||||
return [], "No candidates to analyze"
|
||||
|
||||
system_prompt = """You analyze GitHub issues to identify potential duplicates.
|
||||
|
||||
Given a new issue and a list of existing issues, identify which existing issues are duplicates — meaning
|
||||
they are caused by the SAME BUG in the code, not just similar symptoms.
|
||||
|
||||
CRITICAL DISTINCTION — shared symptoms vs shared root cause:
|
||||
- "models missing", "can't sign in", "editor hangs", "venv not detected" are SYMPTOMS that many
|
||||
different bugs can produce. Two reports of the same symptom are NOT duplicates unless you can
|
||||
identify a specific shared root cause.
|
||||
- A duplicate means: if a developer fixed the existing issue, the new issue would also be fixed.
|
||||
- If the issues just happen to be in the same feature area, or describe similar-sounding problems
|
||||
with different specifics (different error messages, different triggers, different platforms, different
|
||||
configurations), they are NOT duplicates.
|
||||
|
||||
For each potential duplicate, assess confidence:
|
||||
- "high": Almost certainly the same bug. You can name a specific shared root cause, and the
|
||||
reproduction steps / error messages / triggers are consistent.
|
||||
- "medium": Likely the same bug based on specific technical details, but some uncertainty remains.
|
||||
- Do NOT include issues that merely share symptoms, affect the same feature area, or sound similar
|
||||
at a surface level.
|
||||
|
||||
Examples of things that are NOT duplicates:
|
||||
- Two issues about "Copilot models not showing" — one caused by a Zed update breaking the model list,
|
||||
the other caused by the user's plan not including those models.
|
||||
- Two issues about "Zed hangs" — one triggered by network drives, the other by large projects.
|
||||
- Two issues about "can't sign in" — one caused by a missing system package, the other by a server-side error.
|
||||
|
||||
Output only valid JSON (no markdown code blocks) with this structure:
|
||||
{
|
||||
"matches": [
|
||||
{
|
||||
"number": 12345,
|
||||
"confidence": "high|medium",
|
||||
"shared_root_cause": "The specific bug/root cause shared by both issues",
|
||||
"explanation": "Brief explanation with concrete evidence from both issues"
|
||||
}
|
||||
],
|
||||
"summary": "One sentence summary of findings"
|
||||
}
|
||||
|
||||
When in doubt, return an empty matches array. A false positive (flagging a non-duplicate) is much
|
||||
worse than a false negative (missing a real duplicate), because it wastes the time of both the
|
||||
issue author and the maintainers.
|
||||
|
||||
Return empty matches array if none found or if you can only identify shared symptoms."""
|
||||
|
||||
user_content = f"""## New Issue #{issue['number']}
|
||||
**Title:** {issue['title']}
|
||||
|
||||
**Body:**
|
||||
{issue['body'][:3000]}
|
||||
|
||||
## Existing Issues to Compare
|
||||
{json.dumps(candidates, indent=2)}"""
|
||||
|
||||
response = call_claude(anthropic_key, system_prompt, user_content, max_tokens=2048)
|
||||
|
||||
try:
|
||||
data = json.loads(response)
|
||||
except json.JSONDecodeError as e:
|
||||
log(f" Failed to parse response: {e}")
|
||||
log(f" Raw response: {response}")
|
||||
return [], "Failed to parse analysis"
|
||||
|
||||
matches = data.get("matches", [])
|
||||
summary = data.get("summary", "Analysis complete")
|
||||
log(f" Found {len(matches)} potential matches")
|
||||
return matches, summary
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Identify potential duplicate issues")
|
||||
parser.add_argument("issue_number", type=int, help="Issue number to analyze")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Skip posting comment, just log what would be posted")
|
||||
args = parser.parse_args()
|
||||
|
||||
github_token = os.environ.get("GITHUB_TOKEN")
|
||||
anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
|
||||
if not github_token:
|
||||
log("Error: GITHUB_TOKEN not set")
|
||||
sys.exit(1)
|
||||
if not anthropic_key:
|
||||
log("Error: ANTHROPIC_API_KEY not set")
|
||||
sys.exit(1)
|
||||
|
||||
GITHUB_HEADERS = {
|
||||
"Authorization": f"Bearer {github_token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
|
||||
issue = fetch_issue(args.issue_number)
|
||||
if should_skip(issue):
|
||||
print(json.dumps({"skipped": True}))
|
||||
sys.exit(0)
|
||||
|
||||
# detect areas
|
||||
taxonomy = format_taxonomy_for_claude(fetch_area_labels())
|
||||
detected_areas = detect_areas(anthropic_key, issue, taxonomy)
|
||||
|
||||
# search for potential duplicates
|
||||
all_magnets = parse_duplicate_magnets()
|
||||
relevant_magnets = filter_magnets_by_areas(all_magnets, detected_areas)
|
||||
search_results = search_for_similar_issues(issue, detected_areas)
|
||||
|
||||
# analyze potential duplicates
|
||||
if relevant_magnets or search_results:
|
||||
matches, summary = analyze_duplicates(anthropic_key, issue, relevant_magnets, search_results)
|
||||
else:
|
||||
matches, summary = [], "No potential duplicates to analyze"
|
||||
|
||||
# post comment if high-confidence matches found
|
||||
high_confidence_matches = [m for m in matches if m["confidence"] == "high"]
|
||||
commented = False
|
||||
|
||||
if high_confidence_matches:
|
||||
comment_body = build_duplicate_comment(high_confidence_matches)
|
||||
if args.dry_run:
|
||||
log("Dry run - would post comment:\n" + "-" * 40 + "\n" + comment_body + "\n" + "-" * 40)
|
||||
else:
|
||||
log("Posting comment for high-confidence match(es)")
|
||||
try:
|
||||
post_comment(issue["number"], comment_body)
|
||||
commented = True
|
||||
except requests.RequestException as e:
|
||||
log(f" Failed to post comment: {e}")
|
||||
|
||||
print(json.dumps({
|
||||
"skipped": False,
|
||||
"issue": {
|
||||
"number": issue["number"],
|
||||
"title": issue["title"],
|
||||
"author": issue["author"],
|
||||
"type": issue["type"],
|
||||
},
|
||||
"detected_areas": detected_areas,
|
||||
"magnets_count": len(relevant_magnets),
|
||||
"search_results_count": len(search_results),
|
||||
"matches": matches,
|
||||
"summary": summary,
|
||||
"commented": commented,
|
||||
}))
|
||||
105
script/github-clean-issue-types.py
Executable file
105
script/github-clean-issue-types.py
Executable file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Replace 'bug/feature/crash' labels with 'Bug/Feature/Crash' types on open
|
||||
GitHub issues.
|
||||
|
||||
Requires `requests` library and a GitHub access token with "Issues (write)"
|
||||
permission passed as an environment variable.
|
||||
Was used as a quick-and-dirty one-off-bulk-operation script to clean up issue
|
||||
types in the `zed` repository. Leaving it here for reference only; there's no
|
||||
error handling, you've been warned.
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
GITHUB_API_BASE_URL = "https://api.github.com"
|
||||
REPO_OWNER = "zed-industries"
|
||||
REPO_NAME = "zed"
|
||||
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
|
||||
HEADERS = {
|
||||
"Authorization": f"token {GITHUB_TOKEN}",
|
||||
"Accept": "application/vnd.github+json"
|
||||
}
|
||||
LABELS_TO_TYPES = {
|
||||
'bug': 'Bug',
|
||||
'feature': 'Feature',
|
||||
'crash': 'Crash',
|
||||
}
|
||||
|
||||
|
||||
def get_open_issues_without_type(repo):
|
||||
"""Get open issues without type via GitHub's REST API."""
|
||||
issues = []
|
||||
issues_url = f"{GITHUB_API_BASE_URL}/repos/{REPO_OWNER}/{repo}/issues"
|
||||
|
||||
log.info("Start fetching issues from the GitHub API.")
|
||||
params = {
|
||||
"state": "open",
|
||||
"type": "none",
|
||||
"page": 1,
|
||||
"per_page": 100, # worked fine despite the docs saying 30
|
||||
}
|
||||
while True:
|
||||
response = requests.get(issues_url, headers=HEADERS, params=params)
|
||||
response.raise_for_status()
|
||||
issues.extend(response.json())
|
||||
log.info(f"Fetched the next page, total issues so far: {len(issues)}.")
|
||||
|
||||
# is there a next page?
|
||||
link_header = response.headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
params['page'] += 1
|
||||
|
||||
log.info("Done fetching issues.")
|
||||
return issues
|
||||
|
||||
|
||||
def replace_labels_with_types(issues, labels_to_types):
|
||||
"""Replace labels with types, a new attribute of issues.
|
||||
|
||||
Only changes the issues with one type-sounding label, leaving those with
|
||||
two labels (e.g. `bug` *and* `crash`) alone, logging a warning.
|
||||
"""
|
||||
for issue in issues:
|
||||
log.debug(f"Processing issue {issue['number']}.")
|
||||
# for GitHub, all PRs are issues but not all issues are PRs; skip PRs
|
||||
if 'pull_request' in issue:
|
||||
continue
|
||||
issue_labels = (label['name'] for label in issue['labels'])
|
||||
matching_labels = labels_to_types.keys() & set(issue_labels)
|
||||
if len(matching_labels) != 1:
|
||||
log.warning(
|
||||
f"Issue {issue['url']} has either no or multiple type-sounding "
|
||||
"labels, won't be processed.")
|
||||
continue
|
||||
label_to_replace = matching_labels.pop()
|
||||
issue_type = labels_to_types[label_to_replace]
|
||||
log.debug(
|
||||
f"Replacing label {label_to_replace} with type {issue_type} "
|
||||
f"for issue {issue['title']}.")
|
||||
|
||||
# add the type
|
||||
api_url_issue = f"{GITHUB_API_BASE_URL}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue['number']}"
|
||||
add_type_response = requests.patch(
|
||||
api_url_issue, headers=HEADERS, json={"type": issue_type})
|
||||
add_type_response.raise_for_status()
|
||||
log.debug(f"Added type {issue_type} to issue {issue['title']}.")
|
||||
|
||||
# delete the label
|
||||
api_url_delete_label = f"{GITHUB_API_BASE_URL}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue['number']}/labels/{label_to_replace}"
|
||||
delete_response = requests.delete(api_url_delete_label, headers=HEADERS)
|
||||
delete_response.raise_for_status()
|
||||
log.info(
|
||||
f"Deleted label {label_to_replace} from issue {issue['title']}.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
open_issues_without_type = get_open_issues_without_type(REPO_NAME)
|
||||
replace_labels_with_types(open_issues_without_type, LABELS_TO_TYPES)
|
||||
450
script/github-community-pr-board.py
Normal file
450
script/github-community-pr-board.py
Normal file
@@ -0,0 +1,450 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Route community PRs to the correct review track on a GitHub Project board.
|
||||
|
||||
Reads the event payload dispatched by the GitHub Actions workflow and:
|
||||
- On `labeled`: adds the PR to the board (idempotent) and sets the Track
|
||||
field to the most specific matching track.
|
||||
- On `unlabeled`: re-resolves Track from remaining labels, or clears it
|
||||
if no area/platform labels remain (PR stays on the board for visibility).
|
||||
- On `assigned`: if the assignee is a staff team member, sets Status to
|
||||
"In Progress (us)".
|
||||
- On `review_requested`: if current Status is "In Progress (author)",
|
||||
flips it to "In Progress (us)" — the author is explicitly asking for
|
||||
re-review.
|
||||
- On `issue_comment.created`: if the commenter is the PR author and
|
||||
current Status is "In Progress (author)", flips it to
|
||||
"In Progress (us)" — the author is likely signaling they're done.
|
||||
- On `workflow_dispatch`: re-resolves Track for a manually specified PR.
|
||||
|
||||
Review-based status changes (approved → "In Progress (us)", changes
|
||||
requested → "In Progress (author)") are handled by built-in board
|
||||
automations, not this script.
|
||||
|
||||
Requires:
|
||||
requests (pip install requests)
|
||||
|
||||
Usage (called by the workflow, not directly):
|
||||
python github-community-pr-board.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
RETRYABLE_STATUS_CODES = {502, 503, 504}
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAY_SECONDS = 5
|
||||
|
||||
GITHUB_API_URL = "https://api.github.com"
|
||||
REPO_OWNER = "zed-industries"
|
||||
REPO_NAME = "zed"
|
||||
STAFF_TEAM_SLUG = "staff"
|
||||
|
||||
SKIP_LABELS = {"staff", "bot"}
|
||||
|
||||
|
||||
STATUS_IN_PROGRESS_US = "In Progress (us)"
|
||||
STATUS_IN_PROGRESS_AUTHOR = (
|
||||
"In Progress (author)" # set by built-in board automation, read by this script
|
||||
)
|
||||
|
||||
MAPPING_PATH = Path(__file__).parent / "community-pr-track-mapping.json"
|
||||
|
||||
|
||||
def sync_track_from_labels(pr, project_number):
|
||||
"""Sync the PR's Track field on the board with its current labels."""
|
||||
pr_labels = {label["name"] for label in pr.get("labels", [])}
|
||||
track_name = resolve_track(pr_labels, load_mapping())
|
||||
|
||||
project = github_fetch_project(project_number)
|
||||
project_item = github_find_project_item(project["id"], pr["node_id"])
|
||||
|
||||
if not track_name:
|
||||
if project_item:
|
||||
github_clear_field(project, project_item, "Track")
|
||||
print(f"No track matched, cleared Track on PR #{pr['number']}")
|
||||
else:
|
||||
print(
|
||||
f"No track matched for labels on PR #{pr['number']}, not on board, nothing to do"
|
||||
)
|
||||
return
|
||||
|
||||
print(f"Resolved track: {track_name}")
|
||||
|
||||
if not project_item:
|
||||
project_item = github_add_to_project(project["id"], pr["node_id"])
|
||||
github_set_project_field(project, project_item, "Track", track_name)
|
||||
|
||||
|
||||
def set_progress_status_on_assignment(pr, assignee_login, project_number):
|
||||
"""Set Status to 'In Progress (us)' when a staff member self-assigns."""
|
||||
if not github_is_staff_member(assignee_login):
|
||||
print(f"Assignee '{assignee_login}' is not a staff member, skipping")
|
||||
return
|
||||
|
||||
project = github_fetch_project(project_number)
|
||||
item_id = github_find_project_item(project["id"], pr["node_id"])
|
||||
if not item_id:
|
||||
print(f"PR #{pr['number']} not on board, skipping assignment status update")
|
||||
return
|
||||
|
||||
github_set_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US)
|
||||
|
||||
|
||||
def return_to_reviewer(pr, project_number, reason):
|
||||
"""Flip Status from 'In Progress (author)' to 'In Progress (us)'.
|
||||
|
||||
Called when the author signals they're ready for re-review, either
|
||||
by re-requesting review or by commenting on the PR.
|
||||
"""
|
||||
project = github_fetch_project(project_number)
|
||||
item_id = github_find_project_item(project["id"], pr["node_id"])
|
||||
if not item_id:
|
||||
print(f"PR #{pr['number']} not on board, skipping")
|
||||
return
|
||||
|
||||
current_status = github_get_field_value(item_id, "Status")
|
||||
if current_status == STATUS_IN_PROGRESS_AUTHOR:
|
||||
print(
|
||||
f"{reason}, flipping status from '{current_status}' to '{STATUS_IN_PROGRESS_US}'"
|
||||
)
|
||||
github_set_project_field(project, item_id, "Status", STATUS_IN_PROGRESS_US)
|
||||
else:
|
||||
print(f"Current status is '{current_status}', not flipping ({reason})")
|
||||
|
||||
|
||||
def load_mapping(path=MAPPING_PATH):
|
||||
"""Load the Track-to-labels mapping from the JSON file."""
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
return data["tracks"]
|
||||
|
||||
|
||||
def resolve_track(pr_labels, tracks):
|
||||
"""Return the name of the most specific track matching the PR's labels.
|
||||
|
||||
Tracks are checked in order; the first match wins (most specific first).
|
||||
"""
|
||||
for track in tracks:
|
||||
if pr_labels & set(track["labels"]):
|
||||
return track["name"]
|
||||
return None
|
||||
|
||||
|
||||
def github_graphql(query, variables):
|
||||
"""Execute a GitHub GraphQL query. Retries on transient server errors."""
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
response = requests.post(
|
||||
f"{GITHUB_API_URL}/graphql",
|
||||
headers=GITHUB_HEADERS,
|
||||
json={"query": query, "variables": variables},
|
||||
)
|
||||
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
|
||||
print(
|
||||
f"GitHub API returned {response.status_code}, retrying in {RETRY_DELAY_SECONDS}s (attempt {attempt + 1}/{MAX_RETRIES})..."
|
||||
)
|
||||
time.sleep(RETRY_DELAY_SECONDS)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if "errors" in result:
|
||||
raise RuntimeError(f"GraphQL error: {result['errors']}")
|
||||
return result["data"]
|
||||
|
||||
|
||||
def github_rest_get(path):
|
||||
"""GET from the GitHub REST API. Retries on transient server errors."""
|
||||
for attempt in range(MAX_RETRIES + 1):
|
||||
response = requests.get(f"{GITHUB_API_URL}/{path}", headers=GITHUB_HEADERS)
|
||||
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
|
||||
print(
|
||||
f"GitHub API returned {response.status_code}, retrying in {RETRY_DELAY_SECONDS}s (attempt {attempt + 1}/{MAX_RETRIES})..."
|
||||
)
|
||||
time.sleep(RETRY_DELAY_SECONDS)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def github_is_staff_member(username):
|
||||
"""Check if a user is a member of the staff team."""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{GITHUB_API_URL}/orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/members/{username}",
|
||||
headers=GITHUB_HEADERS,
|
||||
)
|
||||
if response.status_code == 204:
|
||||
return True
|
||||
if response.status_code == 404:
|
||||
return False
|
||||
print(
|
||||
f"Warning: unexpected status {response.status_code} checking staff membership for '{username}'"
|
||||
)
|
||||
return False
|
||||
except requests.RequestException as exc:
|
||||
print(f"Warning: failed to check staff membership for '{username}': {exc}")
|
||||
return False
|
||||
|
||||
|
||||
def github_fetch_pr(pr_number):
|
||||
"""Fetch a PR by number via the REST API."""
|
||||
return github_rest_get(f"repos/{REPO_OWNER}/{REPO_NAME}/pulls/{pr_number}")
|
||||
|
||||
|
||||
def github_fetch_project(project_number):
|
||||
"""Fetch a GitHub project board's metadata including fields and their options."""
|
||||
data = github_graphql(
|
||||
"""
|
||||
query($owner: String!, $number: Int!) {
|
||||
organization(login: $owner) {
|
||||
projectV2(number: $number) {
|
||||
id
|
||||
fields(first: 50) {
|
||||
nodes {
|
||||
... on ProjectV2SingleSelectField {
|
||||
id
|
||||
name
|
||||
options { id name }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
{"owner": REPO_OWNER, "number": project_number},
|
||||
)
|
||||
return data["organization"]["projectV2"]
|
||||
|
||||
|
||||
def github_add_to_project(project_id, content_node_id):
|
||||
"""Add a PR to the project board. Returns the new project item ID."""
|
||||
data = github_graphql(
|
||||
"""
|
||||
mutation($projectId: ID!, $contentId: ID!) {
|
||||
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
|
||||
item { id }
|
||||
}
|
||||
}
|
||||
""",
|
||||
{"projectId": project_id, "contentId": content_node_id},
|
||||
)
|
||||
item_id = data["addProjectV2ItemById"]["item"]["id"]
|
||||
print(f"Added PR to board (item: {item_id})")
|
||||
return item_id
|
||||
|
||||
|
||||
def github_find_project_item(project_id, content_node_id):
|
||||
"""Find a PR's item ID on the project board, or None if not present.
|
||||
|
||||
Uses a read-only query so it won't add the PR as a side effect.
|
||||
"""
|
||||
data = github_graphql(
|
||||
"""
|
||||
query($contentId: ID!) {
|
||||
node(id: $contentId) {
|
||||
... on PullRequest {
|
||||
projectItems(first: 50) {
|
||||
nodes {
|
||||
id
|
||||
project { id }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
{"contentId": content_node_id},
|
||||
)
|
||||
for item in data["node"]["projectItems"]["nodes"]:
|
||||
if item["project"]["id"] == project_id:
|
||||
return item["id"]
|
||||
return None
|
||||
|
||||
|
||||
def github_set_project_field(project, item_id, field_name, option_name):
|
||||
"""Set a single-select field on a project item."""
|
||||
field_id = None
|
||||
option_id = None
|
||||
for field in project["fields"]["nodes"]:
|
||||
if field.get("name") == field_name:
|
||||
field_id = field["id"]
|
||||
for option in field.get("options", []):
|
||||
if option["name"] == option_name:
|
||||
option_id = option["id"]
|
||||
break
|
||||
break
|
||||
|
||||
if not field_id:
|
||||
available = [f["name"] for f in project["fields"]["nodes"] if "name" in f]
|
||||
raise RuntimeError(
|
||||
f"Field '{field_name}' not found on project. Available: {available}"
|
||||
)
|
||||
if not option_id:
|
||||
available = [
|
||||
opt["name"]
|
||||
for f in project["fields"]["nodes"]
|
||||
if f.get("name") == field_name
|
||||
for opt in f.get("options", [])
|
||||
]
|
||||
raise RuntimeError(
|
||||
f"Option '{option_name}' not found in field '{field_name}'. "
|
||||
f"Available: {available}"
|
||||
)
|
||||
|
||||
github_graphql(
|
||||
"""
|
||||
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
|
||||
updateProjectV2ItemFieldValue(input: {
|
||||
projectId: $projectId
|
||||
itemId: $itemId
|
||||
fieldId: $fieldId
|
||||
value: { singleSelectOptionId: $optionId }
|
||||
}) {
|
||||
projectV2Item { id }
|
||||
}
|
||||
}
|
||||
""",
|
||||
{
|
||||
"projectId": project["id"],
|
||||
"itemId": item_id,
|
||||
"fieldId": field_id,
|
||||
"optionId": option_id,
|
||||
},
|
||||
)
|
||||
print(f"Set '{field_name}' to '{option_name}'")
|
||||
|
||||
|
||||
def github_clear_field(project, item_id, field_name):
|
||||
"""Clear a single-select field on a project item."""
|
||||
field_id = None
|
||||
for field in project["fields"]["nodes"]:
|
||||
if field.get("name") == field_name:
|
||||
field_id = field["id"]
|
||||
break
|
||||
|
||||
if not field_id:
|
||||
available = [f["name"] for f in project["fields"]["nodes"] if "name" in f]
|
||||
raise RuntimeError(
|
||||
f"Field '{field_name}' not found on project. Available: {available}"
|
||||
)
|
||||
|
||||
github_graphql(
|
||||
"""
|
||||
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!) {
|
||||
clearProjectV2ItemFieldValue(input: {
|
||||
projectId: $projectId
|
||||
itemId: $itemId
|
||||
fieldId: $fieldId
|
||||
}) {
|
||||
projectV2Item { id }
|
||||
}
|
||||
}
|
||||
""",
|
||||
{
|
||||
"projectId": project["id"],
|
||||
"itemId": item_id,
|
||||
"fieldId": field_id,
|
||||
},
|
||||
)
|
||||
print(f"Cleared '{field_name}'")
|
||||
|
||||
|
||||
def github_get_field_value(item_id, field_name):
|
||||
"""Read the current value of a single-select field on a project item."""
|
||||
data = github_graphql(
|
||||
"""
|
||||
query($itemId: ID!) {
|
||||
node(id: $itemId) {
|
||||
... on ProjectV2Item {
|
||||
fieldValues(first: 20) {
|
||||
nodes {
|
||||
... on ProjectV2ItemFieldSingleSelectValue {
|
||||
field { ... on ProjectV2SingleSelectField { name } }
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
{"itemId": item_id},
|
||||
)
|
||||
for field_value in data["node"]["fieldValues"]["nodes"]:
|
||||
if field_value.get("field", {}).get("name") == field_name:
|
||||
return field_value.get("name")
|
||||
return None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
GITHUB_HEADERS = {
|
||||
"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
|
||||
project_number = int(os.environ["PROJECT_NUMBER"])
|
||||
manual_pr_number = os.environ.get("MANUAL_PR_NUMBER")
|
||||
|
||||
if manual_pr_number:
|
||||
pr = github_fetch_pr(manual_pr_number)
|
||||
action = "labeled"
|
||||
event = {}
|
||||
print(f"Manual dispatch for PR #{manual_pr_number}")
|
||||
else:
|
||||
event_name = os.environ["GITHUB_EVENT_NAME"]
|
||||
with open(os.environ["GITHUB_EVENT_PATH"]) as f:
|
||||
event = json.load(f)
|
||||
|
||||
if event_name in ("pull_request", "pull_request_target"):
|
||||
pr = event["pull_request"]
|
||||
action = event["action"]
|
||||
elif event_name == "issue_comment":
|
||||
issue = event["issue"]
|
||||
if "pull_request" not in issue:
|
||||
print("Comment is on an issue, not a PR, skipping")
|
||||
sys.exit(0)
|
||||
commenter = event["comment"]["user"]["login"]
|
||||
pr_author = issue["user"]["login"]
|
||||
if commenter != pr_author:
|
||||
print(
|
||||
f"Commenter '{commenter}' is not PR author '{pr_author}', skipping"
|
||||
)
|
||||
sys.exit(0)
|
||||
pr = github_fetch_pr(issue["number"])
|
||||
action = "author_commented"
|
||||
else:
|
||||
print(f"Unexpected event: {event_name}")
|
||||
sys.exit(0)
|
||||
|
||||
pr_labels = {label["name"] for label in pr.get("labels", [])}
|
||||
if pr_labels & SKIP_LABELS:
|
||||
print(f"Skipping PR #{pr['number']} (has {pr_labels & SKIP_LABELS} label)")
|
||||
sys.exit(0)
|
||||
|
||||
if pr.get("draft"):
|
||||
print(f"Skipping draft PR #{pr['number']}")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"Processing PR #{pr['number']}: action={action}")
|
||||
|
||||
if action in ("labeled", "unlabeled"):
|
||||
sync_track_from_labels(pr, project_number)
|
||||
elif action == "assigned":
|
||||
assignee_login = event.get("assignee", {}).get("login")
|
||||
if not assignee_login:
|
||||
print("No assignee login in event payload, skipping")
|
||||
else:
|
||||
set_progress_status_on_assignment(pr, assignee_login, project_number)
|
||||
elif action == "review_requested":
|
||||
return_to_reviewer(pr, project_number, "Author re-requested review")
|
||||
elif action == "author_commented":
|
||||
return_to_reviewer(pr, project_number, "Author commented on PR")
|
||||
else:
|
||||
print(f"Ignoring action: {action}")
|
||||
223
script/github-find-top-duplicated-bugs.py
Normal file
223
script/github-find-top-duplicated-bugs.py
Normal file
@@ -0,0 +1,223 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Find open issues that have the most duplicates filed against them and update
|
||||
a GitHub issue with the results.
|
||||
|
||||
Queries open issues and looks for MarkedAsDuplicateEvent in their timelines.
|
||||
Only includes issues that have been re-reported at least twice (2+ duplicates
|
||||
closed against them). Groups results by area: label. The output is formatted
|
||||
as markdown with issue URLs (GitHub renders the titles automatically).
|
||||
|
||||
This script is run regularly by the update_duplicate_magnets.yml workflow.
|
||||
|
||||
Requires: requests (pip install requests)
|
||||
GitHub token permissions: issues:write
|
||||
|
||||
Usage:
|
||||
# Print to stdout only for testing:
|
||||
python github-find-top-duplicated-bugs.py --github-token ghp_xxx
|
||||
|
||||
# Update a GitHub issue:
|
||||
python github-find-top-duplicated-bugs.py --github-token ghp_xxx --issue-number 46355
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
import requests
|
||||
|
||||
OWNER = "zed-industries"
|
||||
REPO = "zed"
|
||||
|
||||
GRAPHQL_URL = "https://api.github.com/graphql"
|
||||
REST_API_URL = "https://api.github.com"
|
||||
|
||||
headers = None
|
||||
|
||||
ISSUES_WITH_DUPLICATES_QUERY = """
|
||||
query($owner: String!, $repo: String!, $cursor: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
issues(
|
||||
first: 100
|
||||
after: $cursor
|
||||
states: [OPEN]
|
||||
orderBy: {field: UPDATED_AT, direction: DESC}
|
||||
) {
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
nodes {
|
||||
number
|
||||
url
|
||||
labels(first: 20) {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
}
|
||||
timelineItems(first: 100, itemTypes: [MARKED_AS_DUPLICATE_EVENT]) {
|
||||
nodes {
|
||||
... on MarkedAsDuplicateEvent {
|
||||
duplicate {
|
||||
... on Issue {
|
||||
number
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def extract_duplicate_info(issue):
|
||||
"""Extract duplicate count and info from an issue. Returns None if < 2 duplicates."""
|
||||
seen_duplicates = set()
|
||||
for event in issue["timelineItems"]["nodes"]:
|
||||
try:
|
||||
if event["duplicate"]["state"] == "CLOSED":
|
||||
seen_duplicates.add(event["duplicate"]["number"])
|
||||
except (KeyError, TypeError):
|
||||
continue
|
||||
|
||||
if len(seen_duplicates) < 2:
|
||||
return None
|
||||
|
||||
labels = [l["name"] for l in issue["labels"]["nodes"]]
|
||||
areas = [l.replace("area:", "") for l in labels if l.startswith("area:")]
|
||||
|
||||
return {
|
||||
"number": issue["number"],
|
||||
"url": issue["url"],
|
||||
"areas": areas if areas else ["(unlabeled)"],
|
||||
"duplicate_count": len(seen_duplicates),
|
||||
}
|
||||
|
||||
|
||||
def fetch_canonical_issues_with_duplicates(max_pages=100):
|
||||
"""Fetch open issues and count how many duplicates point to each."""
|
||||
print(f"Finding open issues with the most duplicates in {OWNER}/{REPO}")
|
||||
|
||||
cursor = None
|
||||
duplicate_magnets = []
|
||||
total_issues_scanned = 0
|
||||
|
||||
for page in range(max_pages):
|
||||
response = requests.post(
|
||||
GRAPHQL_URL,
|
||||
headers=headers,
|
||||
json={
|
||||
"query": ISSUES_WITH_DUPLICATES_QUERY,
|
||||
"variables": {"owner": OWNER, "repo": REPO, "cursor": cursor},
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if "errors" in data:
|
||||
print(f"GraphQL errors: {data['errors']}")
|
||||
break
|
||||
|
||||
issues = data["data"]["repository"]["issues"]
|
||||
total_issues_scanned += len(issues["nodes"])
|
||||
|
||||
for issue in issues["nodes"]:
|
||||
if info := extract_duplicate_info(issue):
|
||||
duplicate_magnets.append(info)
|
||||
|
||||
page_info = issues["pageInfo"]
|
||||
if not page_info["hasNextPage"]:
|
||||
print(f"Done: scanned {total_issues_scanned} open issues")
|
||||
break
|
||||
cursor = page_info["endCursor"]
|
||||
|
||||
print(
|
||||
f"Page {page + 1}: scanned {total_issues_scanned} open issues, "
|
||||
f"{len(duplicate_magnets)} have duplicates"
|
||||
)
|
||||
|
||||
return duplicate_magnets
|
||||
|
||||
|
||||
def build_markdown_body(duplicate_magnets):
|
||||
"""Group results by area and build markdown body for the GitHub issue.
|
||||
|
||||
NOTE: the output format is parsed by fetch_duplicate_magnets() in
|
||||
github-check-new-issue-for-duplicates.py — update that if you change this.
|
||||
"""
|
||||
by_area = defaultdict(list)
|
||||
area_totals = Counter()
|
||||
for info in duplicate_magnets:
|
||||
for area in info["areas"]:
|
||||
by_area[area].append(info)
|
||||
area_totals[area] += info["duplicate_count"]
|
||||
|
||||
lines = [
|
||||
"These are the issues that are frequently re-reported. "
|
||||
"The list is generated regularly by running a script."
|
||||
]
|
||||
|
||||
for area, _ in area_totals.most_common():
|
||||
issues = sorted(by_area[area], key=lambda x: x["duplicate_count"], reverse=True)
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"## {area}")
|
||||
lines.append("")
|
||||
|
||||
for info in issues:
|
||||
lines.append(
|
||||
f"- [{info['duplicate_count']:2d} dupes] {info['url']}"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def update_github_issue(issue_number, body):
|
||||
"""Update the body of a GitHub issue."""
|
||||
url = f"{REST_API_URL}/repos/{OWNER}/{REPO}/issues/{issue_number}"
|
||||
response = requests.patch(url, headers=headers, json={"body": body})
|
||||
response.raise_for_status()
|
||||
print(f"Updated issue #{issue_number}")
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Find open issues with the most duplicates filed against them."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--github-token",
|
||||
default=os.environ.get("GITHUB_TOKEN"),
|
||||
help="GitHub token (or set GITHUB_TOKEN env var)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--issue-number",
|
||||
type=int,
|
||||
help="GitHub issue number to update (if not provided, prints to stdout)",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
|
||||
if not args.github_token:
|
||||
print("Error: --github-token is required (or set GITHUB_TOKEN env var)")
|
||||
sys.exit(1)
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {args.github_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
if duplicate_magnets := fetch_canonical_issues_with_duplicates():
|
||||
body = build_markdown_body(duplicate_magnets)
|
||||
if args.issue_number:
|
||||
update_github_issue(args.issue_number, body)
|
||||
else:
|
||||
print(body)
|
||||
93
script/github-label-issues-to-triage.py
Executable file
93
script/github-label-issues-to-triage.py
Executable file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add `state:needs triage` label to open GitHub issues of types Bug and Crash
|
||||
if they're missing area, priority, or frequency labels. Don't touch issues
|
||||
with an assignee or another `state:` label.
|
||||
|
||||
Requires `requests` library and a GitHub access token with "Issues (write)"
|
||||
permission passed as an environment variable. Was used as a quick-and-dirty
|
||||
one-off-bulk-operation script to surface older untriaged issues in the `zed`
|
||||
repository. Leaving it here for reference only; there's no error handling or
|
||||
guardrails, you've been warned.
|
||||
"""
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
GITHUB_API_BASE_URL = "https://api.github.com"
|
||||
REPO_OWNER = "zed-industries"
|
||||
REPO_NAME = "zed"
|
||||
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
|
||||
HEADERS = {
|
||||
"Authorization": f"token {GITHUB_TOKEN}",
|
||||
"Accept": "application/vnd.github+json"
|
||||
}
|
||||
REQUIRED_LABELS_PREFIXES = ["area:", "priority:", "frequency:"]
|
||||
NEEDS_TRIAGE_LABEL = "state:needs triage"
|
||||
|
||||
|
||||
def get_open_issues(repo, issue_type):
|
||||
"""Get open issues of certain type(s) via GitHub's REST API."""
|
||||
issues = []
|
||||
issues_url = f"{GITHUB_API_BASE_URL}/repos/{REPO_OWNER}/{repo}/issues"
|
||||
|
||||
log.info("Start fetching open issues from the GitHub API.")
|
||||
params = {
|
||||
"state": "open",
|
||||
"type": issue_type,
|
||||
"page": 1,
|
||||
"per_page": 100, # worked fine despite the docs saying 30
|
||||
}
|
||||
while True:
|
||||
response = requests.get(issues_url, headers=HEADERS, params=params)
|
||||
response.raise_for_status()
|
||||
issues.extend(response.json())
|
||||
log.info(f"Fetched the next page, total issues so far: {len(issues)}.")
|
||||
|
||||
# is there a next page?
|
||||
link_header = response.headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
params['page'] += 1
|
||||
|
||||
log.info("Done fetching issues.")
|
||||
return issues
|
||||
|
||||
|
||||
def is_untriaged(issue):
|
||||
issue_labels = [label['name'] for label in issue['labels']]
|
||||
# don't want to overwrite existing state labels
|
||||
no_state_label = not any(label.startswith('state:') for label in issue_labels)
|
||||
# we want at least one label for each of the required prefixes
|
||||
has_all_required_labels = all(
|
||||
any(label.startswith(prefix) for label in issue_labels)
|
||||
for prefix in REQUIRED_LABELS_PREFIXES
|
||||
)
|
||||
# let's also assume if we managed to assign an issue it's triaged enough
|
||||
no_assignee = not issue['assignee']
|
||||
return no_state_label and no_assignee and not has_all_required_labels
|
||||
|
||||
|
||||
def label_issues(issues, label):
|
||||
for issue in issues:
|
||||
log.debug(f"Processing issue {issue['number']}.")
|
||||
api_url_add_label = f"{GITHUB_API_BASE_URL}/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue['number']}/labels"
|
||||
add_response = requests.post(
|
||||
api_url_add_label, headers=HEADERS, json={"labels": [label]}
|
||||
)
|
||||
add_response.raise_for_status()
|
||||
log.info(f"Added label '{label}' to issue {issue['title']}.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
open_bugs = get_open_issues(REPO_NAME, "Bug")
|
||||
open_crashes = get_open_issues(REPO_NAME, "Crash")
|
||||
untriaged_issues = filter(
|
||||
is_untriaged, itertools.chain(open_bugs, open_crashes))
|
||||
label_issues(untriaged_issues, label=NEEDS_TRIAGE_LABEL)
|
||||
210
script/github-pr-status
Executable file
210
script/github-pr-status
Executable file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GitHub PR Analyzer for zed-industries/zed repository
|
||||
Downloads all PRs and groups them by first assignee with status, open date, and last updated date.
|
||||
"""
|
||||
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
import json
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
import sys
|
||||
import os
|
||||
|
||||
# GitHub API configuration
|
||||
GITHUB_API_BASE = "https://api.github.com"
|
||||
REPO_OWNER = "zed-industries"
|
||||
REPO_NAME = "zed"
|
||||
GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
|
||||
|
||||
def make_github_request(url, params=None):
|
||||
"""Make a request to GitHub API with proper headers and pagination support."""
|
||||
if params:
|
||||
url_parts = list(urllib.parse.urlparse(url))
|
||||
query = dict(urllib.parse.parse_qsl(url_parts[4]))
|
||||
query.update(params)
|
||||
url_parts[4] = urllib.parse.urlencode(query)
|
||||
url = urllib.parse.urlunparse(url_parts)
|
||||
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Accept", "application/vnd.github.v3+json")
|
||||
req.add_header("User-Agent", "GitHub-PR-Analyzer")
|
||||
|
||||
if GITHUB_TOKEN:
|
||||
req.add_header("Authorization", f"token {GITHUB_TOKEN}")
|
||||
|
||||
try:
|
||||
response = urllib.request.urlopen(req)
|
||||
return response
|
||||
except urllib.error.URLError as e:
|
||||
print(f"Error making request to {url}: {e}")
|
||||
return None
|
||||
except urllib.error.HTTPError as e:
|
||||
print(f"HTTP error {e.code} for {url}: {e.reason}")
|
||||
return None
|
||||
|
||||
def fetch_all_prs():
|
||||
"""Fetch all PRs from the repository using pagination."""
|
||||
prs = []
|
||||
page = 1
|
||||
per_page = 100
|
||||
|
||||
print("Fetching PRs from GitHub API...")
|
||||
|
||||
while True:
|
||||
url = f"{GITHUB_API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/pulls"
|
||||
params = {
|
||||
"state": "open",
|
||||
"sort": "updated",
|
||||
"direction": "desc",
|
||||
"per_page": per_page,
|
||||
"page": page
|
||||
}
|
||||
|
||||
response = make_github_request(url, params)
|
||||
if not response:
|
||||
break
|
||||
|
||||
try:
|
||||
data = response.read().decode('utf-8')
|
||||
page_prs = json.loads(data)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
print(f"Error parsing response: {e}")
|
||||
break
|
||||
|
||||
if not page_prs:
|
||||
break
|
||||
|
||||
prs.extend(page_prs)
|
||||
print(f"Fetched page {page}: {len(page_prs)} PRs (Total: {len(prs)})")
|
||||
|
||||
# Check if we have more pages
|
||||
link_header = response.getheader('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
print(f"Total PRs fetched: {len(prs)}")
|
||||
return prs
|
||||
|
||||
def format_date_as_days_ago(date_string):
|
||||
"""Format ISO date string as 'X days ago'."""
|
||||
if not date_string:
|
||||
return "N/A days ago"
|
||||
|
||||
try:
|
||||
dt = datetime.fromisoformat(date_string.replace('Z', '+00:00'))
|
||||
now = datetime.now(dt.tzinfo)
|
||||
days_diff = (now - dt).days
|
||||
|
||||
if days_diff == 0:
|
||||
return "today"
|
||||
elif days_diff == 1:
|
||||
return "1 day ago"
|
||||
else:
|
||||
return f"{days_diff} days ago"
|
||||
except:
|
||||
return "N/A days ago"
|
||||
|
||||
def get_first_assignee(pr):
|
||||
"""Get the first assignee from a PR, or return 'Unassigned' if none."""
|
||||
assignees = pr.get('assignees', [])
|
||||
if assignees:
|
||||
return assignees[0].get('login', 'Unknown')
|
||||
return 'Unassigned'
|
||||
|
||||
def get_pr_status(pr):
|
||||
"""Determine if PR is draft or ready for review."""
|
||||
if pr.get('draft', False):
|
||||
return "Draft"
|
||||
return "Ready"
|
||||
|
||||
def analyze_prs(prs):
|
||||
"""Group PRs by first assignee and organize the data."""
|
||||
grouped_prs = defaultdict(list)
|
||||
|
||||
for pr in prs:
|
||||
assignee = get_first_assignee(pr)
|
||||
|
||||
pr_info = {
|
||||
'number': pr['number'],
|
||||
'title': pr['title'],
|
||||
'status': get_pr_status(pr),
|
||||
'state': pr['state'],
|
||||
'created_at': format_date_as_days_ago(pr['created_at']),
|
||||
'updated_at': format_date_as_days_ago(pr['updated_at']),
|
||||
'updated_at_raw': pr['updated_at'],
|
||||
'url': pr['html_url'],
|
||||
'author': pr['user']['login']
|
||||
}
|
||||
|
||||
grouped_prs[assignee].append(pr_info)
|
||||
|
||||
# Sort PRs within each group by update date (newest first)
|
||||
for assignee in grouped_prs:
|
||||
grouped_prs[assignee].sort(key=lambda x: x['updated_at_raw'], reverse=True)
|
||||
|
||||
return dict(grouped_prs)
|
||||
|
||||
def print_pr_report(grouped_prs):
|
||||
"""Print formatted report of PRs grouped by assignee."""
|
||||
print(f"OPEN PR REPORT FOR {REPO_OWNER}/{REPO_NAME}")
|
||||
print()
|
||||
|
||||
# Sort assignees alphabetically, but put 'Unassigned' last
|
||||
assignees = sorted(grouped_prs.keys())
|
||||
if 'Unassigned' in assignees:
|
||||
assignees.remove('Unassigned')
|
||||
assignees.append('Unassigned')
|
||||
|
||||
total_prs = sum(len(prs) for prs in grouped_prs.values())
|
||||
print(f"Total Open PRs: {total_prs}")
|
||||
print()
|
||||
|
||||
for assignee in assignees:
|
||||
prs = grouped_prs[assignee]
|
||||
assignee_display = f"@{assignee}" if assignee != 'Unassigned' else assignee
|
||||
print(f"assigned to {assignee_display} ({len(prs)} PRs):")
|
||||
|
||||
for pr in prs:
|
||||
print(f"- {pr['author']}: [{pr['title']}]({pr['url']}) opened:{pr['created_at']} updated:{pr['updated_at']}")
|
||||
|
||||
print()
|
||||
|
||||
def save_json_report(grouped_prs, filename="pr_report.json"):
|
||||
"""Save the PR data to a JSON file."""
|
||||
try:
|
||||
with open(filename, 'w') as f:
|
||||
json.dump(grouped_prs, f, indent=2)
|
||||
print(f"📄 Report saved to {filename}")
|
||||
except Exception as e:
|
||||
print(f"Error saving JSON report: {e}")
|
||||
|
||||
def main():
|
||||
"""Main function to orchestrate the PR analysis."""
|
||||
print("GitHub PR Analyzer")
|
||||
print("==================")
|
||||
|
||||
if not GITHUB_TOKEN:
|
||||
print("⚠️ Warning: GITHUB_TOKEN not set. You may hit rate limits.")
|
||||
print(" Set GITHUB_TOKEN environment variable for authenticated requests.")
|
||||
print()
|
||||
|
||||
# Fetch all PRs
|
||||
prs = fetch_all_prs()
|
||||
|
||||
if not prs:
|
||||
print("❌ Failed to fetch PRs. Please check your connection and try again.")
|
||||
sys.exit(1)
|
||||
|
||||
# Analyze and group PRs
|
||||
grouped_prs = analyze_prs(prs)
|
||||
|
||||
# Print report
|
||||
print_pr_report(grouped_prs)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
518
script/github-track-duplicate-bot-effectiveness.py
Normal file
518
script/github-track-duplicate-bot-effectiveness.py
Normal file
@@ -0,0 +1,518 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Track the effectiveness of the duplicate-detection bot by classifying issues
|
||||
into outcome categories on a GitHub Projects v2 board.
|
||||
|
||||
Subcommands:
|
||||
classify-closed <issue_number> <closer_login> <state_reason>
|
||||
Classify a closed issue and add it to the project board.
|
||||
|
||||
classify-open
|
||||
Classify open, triaged, bot-commented issues and add them to
|
||||
the project board as Noise.
|
||||
|
||||
Requires:
|
||||
requests (pip install requests)
|
||||
|
||||
Environment variables:
|
||||
GITHUB_TOKEN - GitHub App token
|
||||
PROJECT_NUMBER - GitHub Projects v2 board number (default: 76, override for local testing)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import functools
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import requests
|
||||
|
||||
GITHUB_API = "https://api.github.com"
|
||||
GRAPHQL_URL = "https://api.github.com/graphql"
|
||||
REPO_OWNER = "zed-industries"
|
||||
REPO_NAME = "zed"
|
||||
STAFF_TEAM_SLUG = "staff"
|
||||
BOT_LOGIN = "zed-community-bot[bot]"
|
||||
BOT_APP_SLUG = "zed-community-bot"
|
||||
BOT_COMMENT_PREFIX = "This issue appears to be a duplicate of"
|
||||
BOT_START_DATE = "2026-02-18"
|
||||
NEEDS_TRIAGE_LABEL = "state:needs triage"
|
||||
DEFAULT_PROJECT_NUMBER = 76
|
||||
VALID_CLOSED_AS_VALUES = {"duplicate", "not_planned", "completed"}
|
||||
# Add a new tuple when you deploy a new version of the bot that you want to
|
||||
# keep track of (e.g. the prompt gets a rewrite or the model gets swapped).
|
||||
# Newest first, please. The datetime is for the deployment time (merge to maain).
|
||||
BOT_VERSION_TIMELINE = [
|
||||
("v2", datetime(2026, 2, 26, 14, 9, tzinfo=timezone.utc)),
|
||||
("v1", datetime(2026, 2, 18, tzinfo=timezone.utc)),
|
||||
]
|
||||
|
||||
|
||||
def bot_version_for_time(date_string):
|
||||
"""Return the bot version that was active at the given ISO 8601 timestamp."""
|
||||
timestamp = datetime.fromisoformat(date_string.replace("Z", "+00:00"))
|
||||
for version, deployed in BOT_VERSION_TIMELINE:
|
||||
if timestamp >= deployed:
|
||||
return version
|
||||
return BOT_VERSION_TIMELINE[-1][0]
|
||||
|
||||
|
||||
def github_api_get(path, params=None):
|
||||
url = f"{GITHUB_API}/{path.lstrip('/')}"
|
||||
response = requests.get(url, headers=GITHUB_HEADERS, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def github_search_issues(query):
|
||||
"""Search issues, returning most recently created first."""
|
||||
# not handling pagination on purpose: the oldest issues are on the board already
|
||||
params = {"q": query, "sort": "created", "order": "desc", "per_page": 100}
|
||||
return github_api_get("/search/issues", params).get("items", [])
|
||||
|
||||
|
||||
def is_staff_member(username):
|
||||
"""Check if user is an active member of the staff team."""
|
||||
try:
|
||||
data = github_api_get(
|
||||
f"/orgs/{REPO_OWNER}/teams/{STAFF_TEAM_SLUG}/memberships/{username}"
|
||||
)
|
||||
return data.get("state") == "active"
|
||||
except requests.HTTPError as error:
|
||||
if error.response.status_code == 404:
|
||||
return False
|
||||
raise
|
||||
|
||||
|
||||
def fetch_issue(issue_number):
|
||||
data = github_api_get(f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}")
|
||||
return {
|
||||
"number": issue_number,
|
||||
"node_id": data["node_id"],
|
||||
"author": (data.get("user") or {}).get("login", ""),
|
||||
"type_name": (data.get("type") or {}).get("name"),
|
||||
"created_at": data.get("created_at", ""),
|
||||
}
|
||||
|
||||
|
||||
def get_bot_comment_with_time(issue_number):
|
||||
"""Get the bot's duplicate-detection comment and its timestamp from an issue.
|
||||
|
||||
Returns {"body": str, "created_at": str} if found, else None.
|
||||
"""
|
||||
comments_path = f"/repos/{REPO_OWNER}/{REPO_NAME}/issues/{issue_number}/comments"
|
||||
page = 1
|
||||
while comments := github_api_get(comments_path, {"per_page": 100, "page": page}):
|
||||
for comment in comments:
|
||||
author = (comment.get("user") or {}).get("login", "")
|
||||
body = comment.get("body", "")
|
||||
if author == BOT_LOGIN and body.startswith(BOT_COMMENT_PREFIX):
|
||||
return {"body": body, "created_at": comment.get("created_at", "")}
|
||||
page += 1
|
||||
return None
|
||||
|
||||
|
||||
def parse_suggested_issues(comment_body):
|
||||
"""Extract issue numbers from the bot's comment (lines like '- #12345')."""
|
||||
return [int(match) for match in re.findall(r"^- #(\d+)", comment_body, re.MULTILINE)]
|
||||
|
||||
|
||||
def github_api_graphql(query, variables=None, partial_errors_ok=False):
|
||||
"""Execute a GitHub GraphQL query. Raises on errors unless partial_errors_ok is set."""
|
||||
response = requests.post(
|
||||
GRAPHQL_URL,
|
||||
headers=GITHUB_HEADERS,
|
||||
json={"query": query, "variables": variables or {}},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
if "errors" in data:
|
||||
if not partial_errors_ok or "data" not in data:
|
||||
raise RuntimeError(f"GraphQL errors: {data['errors']}")
|
||||
print(f" GraphQL partial errors (ignored): {data['errors']}")
|
||||
return data["data"]
|
||||
|
||||
|
||||
def find_canonical_among(duplicate_number, candidates):
|
||||
"""Check if any candidate issue has duplicate_number marked as a duplicate.
|
||||
|
||||
The MarkedAsDuplicateEvent lives on the canonical issue's timeline, not the
|
||||
duplicate's. So to find which canonical issue our duplicate was closed against,
|
||||
we check each candidate's timeline for a MarkedAsDuplicateEvent whose
|
||||
`duplicate` field matches our issue.
|
||||
|
||||
Returns the matching canonical issue number, or None.
|
||||
"""
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
data = github_api_graphql(
|
||||
"""
|
||||
query($owner: String!, $repo: String!, $numbers: [Int!]!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
PLACEHOLDER
|
||||
}
|
||||
}
|
||||
""".replace("PLACEHOLDER", "\n ".join(
|
||||
f'issue_{number}: issue(number: {number}) {{'
|
||||
f' timelineItems(last: 50, itemTypes: [MARKED_AS_DUPLICATE_EVENT]) {{'
|
||||
f' nodes {{ ... on MarkedAsDuplicateEvent {{ duplicate {{ ... on Issue {{ number }} }} }} }} }} }}'
|
||||
for number in candidates
|
||||
)),
|
||||
{"owner": REPO_OWNER, "repo": REPO_NAME, "numbers": list(candidates)},
|
||||
partial_errors_ok=True,
|
||||
)
|
||||
|
||||
repo = data["repository"]
|
||||
for candidate in candidates:
|
||||
issue_data = repo.get(f"issue_{candidate}")
|
||||
if not issue_data:
|
||||
continue
|
||||
for node in issue_data["timelineItems"]["nodes"]:
|
||||
dup_number = (node.get("duplicate") or {}).get("number")
|
||||
if dup_number == duplicate_number:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
@functools.lru_cache
|
||||
def get_project_config():
|
||||
"""Fetch the project board's ID, field IDs, and option IDs."""
|
||||
data = github_api_graphql(
|
||||
"""
|
||||
query($org: String!, $number: Int!) {
|
||||
organization(login: $org) {
|
||||
projectV2(number: $number) {
|
||||
id
|
||||
fields(first: 30) {
|
||||
nodes {
|
||||
... on ProjectV2SingleSelectField { id name options { id name } }
|
||||
... on ProjectV2Field { id name }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
{"org": REPO_OWNER, "number": PROJECT_NUMBER},
|
||||
)
|
||||
project = data["organization"]["projectV2"]
|
||||
|
||||
config = {"project_id": project["id"], "fields": {}}
|
||||
for field_node in project["fields"]["nodes"]:
|
||||
name = field_node.get("name")
|
||||
if not name:
|
||||
continue
|
||||
field_info = {"id": field_node["id"]}
|
||||
if "options" in field_node:
|
||||
field_info["options"] = {
|
||||
option["name"]: option["id"] for option in field_node["options"]
|
||||
}
|
||||
config["fields"][name] = field_info
|
||||
|
||||
print(f" Project config loaded: {len(config['fields'])} fields")
|
||||
return config
|
||||
|
||||
|
||||
def find_project_item(issue_node_id):
|
||||
"""Check if an issue is already on our project board.
|
||||
|
||||
Returns the project item ID if found, or None.
|
||||
"""
|
||||
data = github_api_graphql(
|
||||
"query($id: ID!) { node(id: $id) { ... on Issue { projectItems(first: 20) { nodes { id project { number } } } } } }",
|
||||
{"id": issue_node_id},
|
||||
)
|
||||
for item in data["node"]["projectItems"]["nodes"]:
|
||||
if item["project"]["number"] == PROJECT_NUMBER:
|
||||
return item["id"]
|
||||
return None
|
||||
|
||||
|
||||
def add_project_item(issue_node_id):
|
||||
"""Add an issue to the project board. Returns the new item ID."""
|
||||
config = get_project_config()
|
||||
data = github_api_graphql(
|
||||
"""
|
||||
mutation($projectId: ID!, $contentId: ID!) {
|
||||
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
|
||||
item { id }
|
||||
}
|
||||
}
|
||||
""",
|
||||
{"projectId": config["project_id"], "contentId": issue_node_id},
|
||||
)
|
||||
return data["addProjectV2ItemById"]["item"]["id"]
|
||||
|
||||
|
||||
def set_field_value(item_id, field_name, value):
|
||||
"""Set a single field value on a project board item."""
|
||||
config = get_project_config()
|
||||
field = config["fields"].get(field_name)
|
||||
if not field:
|
||||
print(f" Warning: field '{field_name}' not found on project board")
|
||||
return
|
||||
|
||||
if "options" in field:
|
||||
# single-select field
|
||||
option_id = field["options"].get(value)
|
||||
if not option_id:
|
||||
print(f" Warning: option '{value}' not found for field '{field_name}'")
|
||||
return
|
||||
field_value = {"singleSelectOptionId": option_id}
|
||||
else:
|
||||
# text field
|
||||
field_value = {"text": str(value)}
|
||||
|
||||
github_api_graphql(
|
||||
"""
|
||||
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $value: ProjectV2FieldValue!) {
|
||||
updateProjectV2ItemFieldValue(input: {
|
||||
projectId: $projectId
|
||||
itemId: $itemId
|
||||
fieldId: $fieldId
|
||||
value: $value
|
||||
}) {
|
||||
projectV2Item { id }
|
||||
}
|
||||
}
|
||||
""",
|
||||
{
|
||||
"projectId": config["project_id"],
|
||||
"itemId": item_id,
|
||||
"fieldId": field["id"],
|
||||
"value": field_value,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def add_or_update_project_item(issue_node_id, outcome, closed_as=None, status="Auto-classified", notes=None, bot_comment_time=None):
|
||||
"""Add an issue to the project board (or update it if already there), setting field values."""
|
||||
item_id = find_project_item(issue_node_id)
|
||||
if item_id:
|
||||
print(f" Issue already on board, updating (item {item_id})")
|
||||
else:
|
||||
item_id = add_project_item(issue_node_id)
|
||||
print(f" Added to project board (item {item_id})")
|
||||
|
||||
set_field_value(item_id, "Outcome", outcome)
|
||||
set_field_value(item_id, "Status", status)
|
||||
|
||||
if closed_as and closed_as in VALID_CLOSED_AS_VALUES:
|
||||
set_field_value(item_id, "Closed as", closed_as)
|
||||
|
||||
if notes:
|
||||
set_field_value(item_id, "Notes", notes)
|
||||
|
||||
if bot_comment_time:
|
||||
set_field_value(item_id, "Bot version", bot_version_for_time(bot_comment_time))
|
||||
|
||||
return item_id
|
||||
|
||||
|
||||
def classify_closed(issue_number, closer_login, state_reason):
|
||||
"""Classify a closed issue and add/update it on the project board."""
|
||||
state_reason = state_reason or "unknown"
|
||||
print(f"Classifying closed issue #{issue_number}")
|
||||
print(f" Closer: {closer_login}, state_reason: {state_reason}")
|
||||
|
||||
issue = fetch_issue(issue_number)
|
||||
author = issue["author"]
|
||||
print(f" Author: {author}, type: {issue['type_name']}")
|
||||
|
||||
if is_staff_member(author):
|
||||
print(f" Skipping: author '{author}' is a staff member")
|
||||
return
|
||||
|
||||
bot_comment = get_bot_comment_with_time(issue_number)
|
||||
bot_commented = bot_comment is not None
|
||||
print(f" Bot commented: {bot_commented}")
|
||||
|
||||
closer_is_author = closer_login == author
|
||||
|
||||
if bot_commented and closer_is_author:
|
||||
classify_as_success(issue, bot_comment, state_reason)
|
||||
elif bot_commented and not closer_is_author:
|
||||
# Only authors, staff, and triagers can close issues, so
|
||||
# a non-author closer is always someone with elevated permissions.
|
||||
classify_non_author_closed(issue, bot_comment, state_reason)
|
||||
elif not bot_commented and state_reason == "duplicate":
|
||||
classify_as_missed_opportunity(issue)
|
||||
else:
|
||||
print(" Skipping: no bot comment and not closed as duplicate")
|
||||
|
||||
|
||||
def classify_as_success(issue, bot_comment, state_reason):
|
||||
"""Author closed their own issue after the bot commented."""
|
||||
if state_reason == "duplicate":
|
||||
status = "Auto-classified"
|
||||
notes = None
|
||||
else:
|
||||
# could be closed for an unrelated reason; flag for review
|
||||
status = "Needs review"
|
||||
notes = f"Author closed as {state_reason}"
|
||||
|
||||
if status == "Auto-classified":
|
||||
print(f" -> Success (closed as {state_reason})")
|
||||
else:
|
||||
print(f" -> Possible Success, needs review ({notes})")
|
||||
add_or_update_project_item(
|
||||
issue["node_id"],
|
||||
outcome="Success",
|
||||
closed_as=state_reason,
|
||||
status=status,
|
||||
notes=notes,
|
||||
bot_comment_time=bot_comment["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def classify_non_author_closed(issue, bot_comment, state_reason):
|
||||
"""Non-author (staff or triager) closed an issue the bot had commented on."""
|
||||
if state_reason == "duplicate":
|
||||
classify_as_assist(issue, bot_comment)
|
||||
else:
|
||||
notes = f"Closed by staff/triager as {state_reason}, not duplicate"
|
||||
print(f" -> Possible Noise, needs review ({notes})")
|
||||
add_or_update_project_item(
|
||||
issue["node_id"],
|
||||
outcome="Noise",
|
||||
closed_as=state_reason,
|
||||
status="Needs review",
|
||||
notes=notes,
|
||||
bot_comment_time=bot_comment["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def classify_as_assist(issue, bot_comment):
|
||||
"""Staff member closed as duplicate after the bot commented. Check if the dup matches."""
|
||||
suggested = parse_suggested_issues(bot_comment["body"])
|
||||
if not suggested:
|
||||
print(" -> Assist, needs review (could not parse bot suggestions)")
|
||||
add_or_update_project_item(
|
||||
issue["node_id"], outcome="Assist", closed_as="duplicate",
|
||||
status="Needs review", notes="Could not parse bot suggestions",
|
||||
bot_comment_time=bot_comment["created_at"])
|
||||
return
|
||||
|
||||
original = None
|
||||
try:
|
||||
original = find_canonical_among(issue["number"], suggested)
|
||||
except (requests.RequestException, RuntimeError) as error:
|
||||
print(f" Warning: failed to query candidate timelines: {error}")
|
||||
|
||||
if original:
|
||||
status = "Auto-classified"
|
||||
notes = None
|
||||
print(f" -> Assist (original #{original} matches bot suggestion)")
|
||||
else:
|
||||
status = "Needs review"
|
||||
suggested_str = ", ".join(f"#{number}" for number in suggested)
|
||||
notes = f"Bot suggested {suggested_str}; none matched as canonical"
|
||||
print(f" -> Possible Assist, needs review ({notes})")
|
||||
|
||||
add_or_update_project_item(
|
||||
issue["node_id"], outcome="Assist", closed_as="duplicate", status=status, notes=notes,
|
||||
bot_comment_time=bot_comment["created_at"])
|
||||
|
||||
|
||||
def classify_as_missed_opportunity(issue):
|
||||
"""Issue closed as duplicate but the bot never commented."""
|
||||
print(" -> Missed opportunity")
|
||||
add_or_update_project_item(
|
||||
issue["node_id"], outcome="Missed opportunity", closed_as="duplicate", status="Auto-classified",
|
||||
bot_comment_time=issue["created_at"])
|
||||
|
||||
|
||||
def classify_open():
|
||||
"""Classify open, triaged, bot-commented issues as Noise."""
|
||||
print("Classifying open issues")
|
||||
|
||||
query = (
|
||||
f"repo:{REPO_OWNER}/{REPO_NAME} is:issue is:open "
|
||||
f"commenter:app/{BOT_APP_SLUG} "
|
||||
f'-label:"{NEEDS_TRIAGE_LABEL}" '
|
||||
f"created:>={BOT_START_DATE}"
|
||||
)
|
||||
print(f" Search query: {query}")
|
||||
|
||||
results = github_search_issues(query)
|
||||
print(f" Found {len(results)} candidate issues")
|
||||
|
||||
added, skipped, errors = 0, 0, 0
|
||||
for item in results:
|
||||
number = item["number"]
|
||||
try:
|
||||
type_name = (item.get("type") or {}).get("name")
|
||||
author = (item.get("user") or {}).get("login", "")
|
||||
node_id = item["node_id"]
|
||||
|
||||
skip_reason = (
|
||||
f"type is {type_name}" if type_name not in ("Bug", "Crash")
|
||||
else f"author {author} is staff" if is_staff_member(author)
|
||||
else "already on the board" if find_project_item(node_id)
|
||||
else "no bot duplicate comment found" if not (bot_comment := get_bot_comment_with_time(number))
|
||||
else None
|
||||
)
|
||||
|
||||
if skip_reason:
|
||||
print(f" #{number}: skipping, {skip_reason}")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
print(f" #{number}: adding as Noise")
|
||||
add_or_update_project_item(node_id, outcome="Noise", status="Auto-classified",
|
||||
bot_comment_time=bot_comment["created_at"])
|
||||
added += 1
|
||||
except Exception as error: # broad catch: one issue failing shouldn't stop the sweep
|
||||
print(f" #{number}: error processing issue, skipping: {error}")
|
||||
errors += 1
|
||||
|
||||
print(f" Done: added {added}, skipped {skipped}, errors {errors}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Track duplicate bot effectiveness on a GitHub project board.",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
classify_parser = subparsers.add_parser(
|
||||
"classify-closed",
|
||||
help="Classify a closed issue and add it to the project board.",
|
||||
)
|
||||
classify_parser.add_argument("issue_number", type=int)
|
||||
classify_parser.add_argument("closer_login")
|
||||
classify_parser.add_argument("state_reason")
|
||||
|
||||
subparsers.add_parser(
|
||||
"classify-open",
|
||||
help="Classify open, triaged, bot-commented issues as Noise.",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "")
|
||||
if not GITHUB_TOKEN:
|
||||
print("Error: GITHUB_TOKEN environment variable is required")
|
||||
sys.exit(1)
|
||||
|
||||
raw_project_number = os.environ.get("PROJECT_NUMBER", "")
|
||||
if raw_project_number:
|
||||
try:
|
||||
PROJECT_NUMBER = int(raw_project_number)
|
||||
except ValueError:
|
||||
print(f"Error: PROJECT_NUMBER must be an integer, got '{raw_project_number}'")
|
||||
sys.exit(1)
|
||||
else:
|
||||
PROJECT_NUMBER = DEFAULT_PROJECT_NUMBER
|
||||
|
||||
GITHUB_HEADERS = {
|
||||
"Authorization": f"token {GITHUB_TOKEN}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
|
||||
if args.command == "classify-closed":
|
||||
classify_closed(args.issue_number, args.closer_login, args.state_reason)
|
||||
elif args.command == "classify-open":
|
||||
classify_open()
|
||||
74
script/histogram
Executable file
74
script/histogram
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Required dependencies for this script:
|
||||
#
|
||||
# pandas: For data manipulation and analysis.
|
||||
# matplotlib: For creating static, interactive, and animated visualizations in Python.
|
||||
# seaborn: For making statistical graphics in Python, based on matplotlib.
|
||||
|
||||
# To install these dependencies, use the following pip command:
|
||||
# pip install pandas matplotlib seaborn
|
||||
|
||||
# This script is designed to parse log files for performance measurements and create histograms of these measurements.
|
||||
# It expects log files to contain lines with measurements in the format "measurement: timeunit" where timeunit can be in milliseconds (ms) or microseconds (µs).
|
||||
# Lines that do not contain a colon ':' are skipped.
|
||||
# The script takes one or more file paths as command-line arguments, parses each log file, and then combines the data into a single DataFrame.
|
||||
# It then converts all time measurements into milliseconds, discards the original time and unit columns, and creates histograms for each unique measurement type.
|
||||
# The histograms display the distribution of times for each measurement, separated by log file, and normalized to show density rather than count.
|
||||
# To use this script, run it from the command line with the log file paths as arguments, like so:
|
||||
# python this_script.py log1.txt log2.txt ...
|
||||
# The script will then parse the provided log files and display the histograms for each type of measurement found.
|
||||
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
import seaborn as sns
|
||||
import sys
|
||||
|
||||
def parse_log_file(file_path):
|
||||
data = {'measurement': [], 'time': [], 'unit': [], 'log_file': []}
|
||||
with open(file_path, 'r') as file:
|
||||
for line in file:
|
||||
if ':' not in line:
|
||||
continue
|
||||
|
||||
parts = line.strip().split(': ')
|
||||
if len(parts) != 2:
|
||||
continue
|
||||
|
||||
measurement, time_with_unit = parts[0], parts[1]
|
||||
if 'ms' in time_with_unit:
|
||||
time, unit = time_with_unit[:-2], 'ms'
|
||||
elif 'µs' in time_with_unit:
|
||||
time, unit = time_with_unit[:-2], 'µs'
|
||||
else:
|
||||
# Print an error message if we can't parse the line and then continue with rest.
|
||||
print(f'Error: Invalid time unit in line "{line.strip()}". Skipping.', file=sys.stderr)
|
||||
continue
|
||||
|
||||
data['measurement'].append(measurement)
|
||||
data['time'].append(float(time))
|
||||
data['unit'].append(unit)
|
||||
data['log_file'].append(file_path.split('/')[-1])
|
||||
return pd.DataFrame(data)
|
||||
|
||||
def create_histograms(df, measurement):
|
||||
filtered_df = df[df['measurement'] == measurement]
|
||||
plt.figure(figsize=(12, 6))
|
||||
sns.histplot(data=filtered_df, x='time_ms', hue='log_file', element='step', stat='density', common_norm=False, palette='bright')
|
||||
plt.title(f'Histogram of {measurement}')
|
||||
plt.xlabel('Time (ms)')
|
||||
plt.ylabel('Density')
|
||||
plt.grid(True)
|
||||
plt.xlim(filtered_df['time_ms'].quantile(0.01), filtered_df['time_ms'].quantile(0.99))
|
||||
plt.show()
|
||||
|
||||
|
||||
file_paths = sys.argv[1:]
|
||||
dfs = [parse_log_file(path) for path in file_paths]
|
||||
combined_df = pd.concat(dfs, ignore_index=True)
|
||||
combined_df['time_ms'] = combined_df.apply(lambda row: row['time'] if row['unit'] == 'ms' else row['time'] / 1000, axis=1)
|
||||
combined_df.drop(['time', 'unit'], axis=1, inplace=True)
|
||||
|
||||
measurement_types = combined_df['measurement'].unique()
|
||||
for measurement in measurement_types:
|
||||
create_histograms(combined_df, measurement)
|
||||
3
script/import-themes
Executable file
3
script/import-themes
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
cargo run -p theme_importer -- "$@"
|
||||
77
script/install-cmake
Executable file
77
script/install-cmake
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# This script installs an up-to-date version of CMake.
|
||||
#
|
||||
# For MacOS use Homebrew to install the latest version.
|
||||
#
|
||||
# For Ubuntu use the official KitWare Apt repository with backports.
|
||||
# See: https://apt.kitware.com/
|
||||
#
|
||||
# For other systems (RHEL 8.x, 9.x, AmazonLinux, SUSE, Fedora, Arch, etc)
|
||||
# use the official CMake installer script from KitWare.
|
||||
#
|
||||
# Note this is similar to how GitHub Actions runners install cmake:
|
||||
# https://github.com/actions/runner-images/blob/main/images/ubuntu/scripts/build/install-cmake.sh
|
||||
#
|
||||
# Upstream: 3.30.4 (2024-09-27)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
if [[ "$(uname -s)" == "darwin" ]]; then
|
||||
brew --version >/dev/null \
|
||||
|| echo "Error: Homebrew is required to install cmake on MacOS." && exit 1
|
||||
echo "Installing cmake via Homebrew (can't pin to old versions)."
|
||||
brew install cmake
|
||||
exit 0
|
||||
elif [ "$(uname -s)" != "Linux" ]; then
|
||||
echo "Error: This script is intended for MacOS/Linux systems only."
|
||||
exit 1
|
||||
elif [ -z "${1:-}" ]; then
|
||||
echo "Usage: $0 [3.30.4]"
|
||||
exit 1
|
||||
fi
|
||||
CMAKE_VERSION="${CMAKE_VERSION:-${1:-3.30.4}}"
|
||||
|
||||
if [ "$(whoami)" = root ]; then SUDO=; else SUDO="$(command -v sudo || command -v doas || true)"; fi
|
||||
|
||||
if cmake --version 2>/dev/null | grep -q "$CMAKE_VERSION"; then
|
||||
echo "CMake $CMAKE_VERSION is already installed."
|
||||
exit 0
|
||||
elif [ -e /usr/local/bin/cmake ]; then
|
||||
echo "Warning: existing cmake found at /usr/local/bin/cmake. Skipping installation."
|
||||
exit 0
|
||||
elif [ -e /etc/apt/sources.list.d/kitware.list ]; then
|
||||
echo "Warning: existing KitWare repository found. Skipping installation."
|
||||
exit 0
|
||||
elif [ -e /etc/lsb-release ] && grep -qP 'DISTRIB_ID=Ubuntu' /etc/lsb-release; then
|
||||
curl -fsSL https://apt.kitware.com/keys/kitware-archive-latest.asc \
|
||||
| $SUDO gpg --dearmor - \
|
||||
| $SUDO tee /usr/share/keyrings/kitware-archive-keyring.gpg >/dev/null
|
||||
echo "deb [signed-by=/usr/share/keyrings/kitware-archive-keyring.gpg] https://apt.kitware.com/ubuntu/ $(lsb_release -cs) main" \
|
||||
| $SUDO tee /etc/apt/sources.list.d/kitware.list >/dev/null
|
||||
$SUDO apt-get update
|
||||
$SUDO apt-get install -y kitware-archive-keyring cmake
|
||||
else
|
||||
arch="$(uname -m)"
|
||||
if [ "$arch" != "x86_64" ] && [ "$arch" != "aarch64" ]; then
|
||||
echo "Error. Only x86_64 and aarch64 are supported."
|
||||
exit 1
|
||||
fi
|
||||
tempdir=$(mktemp -d)
|
||||
pushd "$tempdir"
|
||||
CMAKE_REPO="https://github.com/Kitware/CMake"
|
||||
CMAKE_INSTALLER="cmake-$CMAKE_VERSION-linux-$arch.sh"
|
||||
curl -fsSL --output cmake-$CMAKE_VERSION-SHA-256.txt \
|
||||
"$CMAKE_REPO/releases/download/v$CMAKE_VERSION/cmake-$CMAKE_VERSION-SHA-256.txt"
|
||||
curl -fsSL --output $CMAKE_INSTALLER \
|
||||
"$CMAKE_REPO/releases/download/v$CMAKE_VERSION/cmake-$CMAKE_VERSION-linux-$arch.sh"
|
||||
# workaround for old versions of sha256sum not having --ignore-missing
|
||||
grep -F "cmake-$CMAKE_VERSION-linux-$arch.sh" "cmake-$CMAKE_VERSION-SHA-256.txt" \
|
||||
| sha256sum -c \
|
||||
| grep -qP "^${CMAKE_INSTALLER}: OK"
|
||||
chmod +x cmake-$CMAKE_VERSION-linux-$arch.sh
|
||||
$SUDO ./cmake-$CMAKE_VERSION-linux-$arch.sh --prefix=/usr/local --skip-license
|
||||
popd
|
||||
rm -rf "$tempdir"
|
||||
fi
|
||||
22
script/install-linux
Executable file
22
script/install-linux
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
if [[ $# -gt 0 ]]; then
|
||||
echo "
|
||||
Usage: ${0##*/}
|
||||
Builds and installs zed onto your system into ~/.local, making it available as ~/.local/bin/zed.
|
||||
|
||||
Before running this you should ensure you have all the build dependencies installed with `./script/linux`.
|
||||
"
|
||||
exit 1
|
||||
fi
|
||||
export ZED_CHANNEL=$(<crates/zed/RELEASE_CHANNEL)
|
||||
export ZED_UPDATE_EXPLANATION="You need to fetch and rebuild zed in $(pwd)"
|
||||
script/bundle-linux
|
||||
|
||||
arch="$(uname -m)"
|
||||
commit=$(git rev-parse HEAD | cut -c 1-7)
|
||||
archive="zed-linux-${arch}.tar.gz"
|
||||
export ZED_BUNDLE_PATH="${CARGO_TARGET_DIR:-target}/release/${archive}"
|
||||
script/install.sh
|
||||
39
script/install-rustup.ps1
Normal file
39
script/install-rustup.ps1
Normal file
@@ -0,0 +1,39 @@
|
||||
# Checks if cargo is in the user's path or in default install path
|
||||
# If not, download with rustup-installer (which respects CARGO_HOME / RUSTUP_HOME)
|
||||
|
||||
# Like 'set -e' in bash
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$cargoHome = if ($env:CARGO_HOME) { $env:CARGO_HOME } else { "$env:USERPROFILE\.cargo" }
|
||||
$rustupPath = "$cargoHome\bin\rustup.exe"
|
||||
$cargoPath = "$cargoHome\bin\cargo.exe"
|
||||
|
||||
# Check if cargo is already available in path
|
||||
if (Get-Command cargo -ErrorAction SilentlyContinue)
|
||||
{
|
||||
cargo --version
|
||||
exit
|
||||
}
|
||||
# Check if rustup and cargo are available in CARGO_HOME
|
||||
elseif (-not ((Test-Path $rustupPath) -and (Test-Path $cargoPath))) {
|
||||
Write-Output "Rustup or Cargo not found in $cargoHome, installing..."
|
||||
|
||||
$tempDir = [System.IO.Path]::GetTempPath()
|
||||
|
||||
# Download and install rustup
|
||||
$RustupInitPath = "$tempDir\rustup-init.exe"
|
||||
Write-Output "Downloading rustup installer..."
|
||||
Invoke-WebRequest `
|
||||
-OutFile $RustupInitPath `
|
||||
-Uri https://static.rust-lang.org/rustup/dist/i686-pc-windows-gnu/rustup-init.exe
|
||||
|
||||
Write-Output "Installing rustup..."
|
||||
& $RustupInitPath -y --default-toolchain none
|
||||
Remove-Item -Force $RustupInitPath
|
||||
|
||||
Write-Output "Rust installation complete."
|
||||
# This is necessary
|
||||
}
|
||||
|
||||
& $rustupPath --version
|
||||
& $cargoPath --version
|
||||
161
script/install.sh
Executable file
161
script/install.sh
Executable file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
# Downloads a tarball from https://zed.dev/releases and unpacks it
|
||||
# into ~/.local/. If you'd prefer to do this manually, instructions are at
|
||||
# https://zed.dev/docs/linux.
|
||||
|
||||
main() {
|
||||
platform="$(uname -s)"
|
||||
arch="$(uname -m)"
|
||||
channel="${ZED_CHANNEL:-stable}"
|
||||
ZED_VERSION="${ZED_VERSION:-latest}"
|
||||
# Use TMPDIR if available (for environments with non-standard temp directories)
|
||||
if [ -n "${TMPDIR:-}" ] && [ -d "${TMPDIR}" ]; then
|
||||
temp="$(mktemp -d "$TMPDIR/zed-XXXXXX")"
|
||||
else
|
||||
temp="$(mktemp -d "/tmp/zed-XXXXXX")"
|
||||
fi
|
||||
|
||||
if [ "$platform" = "Darwin" ]; then
|
||||
platform="macos"
|
||||
elif [ "$platform" = "Linux" ]; then
|
||||
platform="linux"
|
||||
else
|
||||
echo "Unsupported platform $platform"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case "$platform-$arch" in
|
||||
macos-arm64* | linux-arm64* | linux-armhf | linux-aarch64)
|
||||
arch="aarch64"
|
||||
;;
|
||||
macos-x86* | linux-x86* | linux-i686*)
|
||||
arch="x86_64"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported platform or architecture"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl () {
|
||||
command curl -fL "$@"
|
||||
}
|
||||
elif command -v wget >/dev/null 2>&1; then
|
||||
curl () {
|
||||
wget -O- "$@"
|
||||
}
|
||||
else
|
||||
echo "Could not find 'curl' or 'wget' in your path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$platform" "$@"
|
||||
|
||||
if [ "$(command -v zed)" = "$HOME/.local/bin/zed" ]; then
|
||||
echo "Zed has been installed. Run with 'zed'"
|
||||
else
|
||||
echo "To run Zed from your terminal, you must add ~/.local/bin to your PATH"
|
||||
echo "Run:"
|
||||
|
||||
case "$SHELL" in
|
||||
*zsh)
|
||||
echo " echo 'export PATH=\$HOME/.local/bin:\$PATH' >> ~/.zshrc"
|
||||
echo " source ~/.zshrc"
|
||||
;;
|
||||
*fish)
|
||||
echo " fish_add_path -U $HOME/.local/bin"
|
||||
;;
|
||||
*)
|
||||
echo " echo 'export PATH=\$HOME/.local/bin:\$PATH' >> ~/.bashrc"
|
||||
echo " source ~/.bashrc"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "To run Zed now, '~/.local/bin/zed'"
|
||||
fi
|
||||
}
|
||||
|
||||
linux() {
|
||||
if [ -n "${ZED_BUNDLE_PATH:-}" ]; then
|
||||
cp "$ZED_BUNDLE_PATH" "$temp/zed-linux-$arch.tar.gz"
|
||||
else
|
||||
echo "Downloading Zed version: $ZED_VERSION"
|
||||
curl "https://cloud.zed.dev/releases/$channel/$ZED_VERSION/download?asset=zed&arch=$arch&os=linux&source=install.sh" > "$temp/zed-linux-$arch.tar.gz"
|
||||
fi
|
||||
|
||||
suffix=""
|
||||
if [ "$channel" != "stable" ]; then
|
||||
suffix="-$channel"
|
||||
fi
|
||||
|
||||
appid=""
|
||||
case "$channel" in
|
||||
stable)
|
||||
appid="dev.zed.Zed"
|
||||
;;
|
||||
nightly)
|
||||
appid="dev.zed.Zed-Nightly"
|
||||
;;
|
||||
preview)
|
||||
appid="dev.zed.Zed-Preview"
|
||||
;;
|
||||
dev)
|
||||
appid="dev.zed.Zed-Dev"
|
||||
;;
|
||||
*)
|
||||
echo "Unknown release channel: ${channel}. Using stable app ID."
|
||||
appid="dev.zed.Zed"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Unpack
|
||||
rm -rf "$HOME/.local/zed$suffix.app"
|
||||
mkdir -p "$HOME/.local/zed$suffix.app"
|
||||
tar -xzf "$temp/zed-linux-$arch.tar.gz" -C "$HOME/.local/"
|
||||
|
||||
# Setup ~/.local directories
|
||||
mkdir -p "$HOME/.local/bin" "$HOME/.local/share/applications"
|
||||
|
||||
# Link the binary
|
||||
if [ -f "$HOME/.local/zed$suffix.app/bin/zed" ]; then
|
||||
ln -sf "$HOME/.local/zed$suffix.app/bin/zed" "$HOME/.local/bin/zed"
|
||||
else
|
||||
# support for versions before 0.139.x.
|
||||
ln -sf "$HOME/.local/zed$suffix.app/bin/cli" "$HOME/.local/bin/zed"
|
||||
fi
|
||||
|
||||
# Copy .desktop file
|
||||
desktop_file_path="$HOME/.local/share/applications/${appid}.desktop"
|
||||
src_dir="$HOME/.local/zed$suffix.app/share/applications"
|
||||
if [ -f "$src_dir/${appid}.desktop" ]; then
|
||||
cp "$src_dir/${appid}.desktop" "${desktop_file_path}"
|
||||
else
|
||||
# Fallback for older tarballs
|
||||
cp "$src_dir/zed$suffix.desktop" "${desktop_file_path}"
|
||||
fi
|
||||
sed -i "s|Icon=zed|Icon=$HOME/.local/zed$suffix.app/share/icons/hicolor/512x512/apps/zed.png|g" "${desktop_file_path}"
|
||||
sed -i "s|Exec=zed|Exec=$HOME/.local/zed$suffix.app/bin/zed|g" "${desktop_file_path}"
|
||||
}
|
||||
|
||||
macos() {
|
||||
echo "Downloading Zed version: $ZED_VERSION"
|
||||
curl "https://cloud.zed.dev/releases/$channel/$ZED_VERSION/download?asset=zed&os=macos&arch=$arch&source=install.sh" > "$temp/Zed-$arch.dmg"
|
||||
hdiutil attach -quiet "$temp/Zed-$arch.dmg" -mountpoint "$temp/mount"
|
||||
app="$(cd "$temp/mount/"; echo *.app)"
|
||||
echo "Installing $app"
|
||||
if [ -d "/Applications/$app" ]; then
|
||||
echo "Removing existing $app"
|
||||
rm -rf "/Applications/$app"
|
||||
fi
|
||||
ditto "$temp/mount/$app" "/Applications/$app"
|
||||
hdiutil detach -quiet "$temp/mount"
|
||||
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
# Link the binary
|
||||
ln -sf "/Applications/$app/Contents/MacOS/cli" "$HOME/.local/bin/zed"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
11
script/kube-shell
Executable file
11
script/kube-shell
Executable file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "Usage: $0 [production|staging|...]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export ZED_KUBE_NAMESPACE=$1
|
||||
|
||||
pod=$(kubectl --namespace=${ZED_KUBE_NAMESPACE} get pods --selector=app=zed --output=jsonpath='{.items[*].metadata.name}')
|
||||
exec kubectl --namespace $ZED_KUBE_NAMESPACE exec --tty --stdin $pod -- /bin/bash
|
||||
68
script/lib/blob-store.ps1
Normal file
68
script/lib/blob-store.ps1
Normal file
@@ -0,0 +1,68 @@
|
||||
function UploadToBlobStoreWithACL {
|
||||
param (
|
||||
[string]$BucketName,
|
||||
[string]$FileToUpload,
|
||||
[string]$BlobStoreKey,
|
||||
[string]$ACL
|
||||
)
|
||||
|
||||
# Format date to match AWS requirements
|
||||
$Date = (Get-Date).ToUniversalTime().ToString("r")
|
||||
# Note: Original script had a bug where it overrode the ACL parameter
|
||||
# I'm keeping the same behavior for compatibility
|
||||
$ACL = "public-read"
|
||||
$ContentType = "application/octet-stream"
|
||||
$StorageClass = "STANDARD"
|
||||
|
||||
# Create string to sign (AWS S3 compatible format)
|
||||
$StringToSign = "PUT`n`n${ContentType}`n${Date}`nx-amz-acl:${ACL}`nx-amz-storage-class:${StorageClass}`n/${BucketName}/${BlobStoreKey}"
|
||||
|
||||
# Generate HMAC-SHA1 signature
|
||||
$HMACSHA1 = New-Object System.Security.Cryptography.HMACSHA1
|
||||
$HMACSHA1.Key = [System.Text.Encoding]::UTF8.GetBytes($env:DIGITALOCEAN_SPACES_SECRET_KEY)
|
||||
$Signature = [System.Convert]::ToBase64String($HMACSHA1.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($StringToSign)))
|
||||
|
||||
# Upload file using Invoke-WebRequest (equivalent to curl)
|
||||
$Headers = @{
|
||||
"Host" = "${BucketName}.nyc3.digitaloceanspaces.com"
|
||||
"Date" = $Date
|
||||
"Content-Type" = $ContentType
|
||||
"x-amz-storage-class" = $StorageClass
|
||||
"x-amz-acl" = $ACL
|
||||
"Authorization" = "AWS ${env:DIGITALOCEAN_SPACES_ACCESS_KEY}:$Signature"
|
||||
}
|
||||
|
||||
$Uri = "https://${BucketName}.nyc3.digitaloceanspaces.com/${BlobStoreKey}"
|
||||
|
||||
# Read file content
|
||||
$FileContent = Get-Content $FileToUpload -Raw -AsByteStream
|
||||
|
||||
try {
|
||||
Invoke-WebRequest -Uri $Uri -Method PUT -Headers $Headers -Body $FileContent -ContentType $ContentType -Verbose
|
||||
Write-Host "Successfully uploaded $FileToUpload to $Uri" -ForegroundColor Green
|
||||
}
|
||||
catch {
|
||||
Write-Error "Failed to upload file: $_"
|
||||
throw $_
|
||||
}
|
||||
}
|
||||
|
||||
function UploadToBlobStorePublic {
|
||||
param (
|
||||
[string]$BucketName,
|
||||
[string]$FileToUpload,
|
||||
[string]$BlobStoreKey
|
||||
)
|
||||
|
||||
UploadToBlobStoreWithACL -BucketName $BucketName -FileToUpload $FileToUpload -BlobStoreKey $BlobStoreKey -ACL "public-read"
|
||||
}
|
||||
|
||||
function UploadToBlobStore {
|
||||
param (
|
||||
[string]$BucketName,
|
||||
[string]$FileToUpload,
|
||||
[string]$BlobStoreKey
|
||||
)
|
||||
|
||||
UploadToBlobStoreWithACL -BucketName $BucketName -FileToUpload $FileToUpload -BlobStoreKey $BlobStoreKey -ACL "private"
|
||||
}
|
||||
32
script/lib/blob-store.sh
Normal file
32
script/lib/blob-store.sh
Normal file
@@ -0,0 +1,32 @@
|
||||
function upload_to_blob_store_with_acl
|
||||
{
|
||||
bucket_name="$1"
|
||||
file_to_upload="$2"
|
||||
blob_store_key="$3"
|
||||
acl="$4"
|
||||
|
||||
date=$(date +"%a, %d %b %Y %T %z")
|
||||
content_type="application/octet-stream"
|
||||
storage_type="x-amz-storage-class:STANDARD"
|
||||
string="PUT\n\n${content_type}\n${date}\n${acl}\n${storage_type}\n/${bucket_name}/${blob_store_key}"
|
||||
signature=$(echo -en "${string}" | openssl sha1 -hmac "${DIGITALOCEAN_SPACES_SECRET_KEY}" -binary | base64)
|
||||
|
||||
curl --fail -vv -s -X PUT -T "$file_to_upload" \
|
||||
-H "Host: ${bucket_name}.nyc3.digitaloceanspaces.com" \
|
||||
-H "Date: $date" \
|
||||
-H "Content-Type: $content_type" \
|
||||
-H "$storage_type" \
|
||||
-H "$acl" \
|
||||
-H "Authorization: AWS ${DIGITALOCEAN_SPACES_ACCESS_KEY}:$signature" \
|
||||
"https://${bucket_name}.nyc3.digitaloceanspaces.com/${blob_store_key}"
|
||||
}
|
||||
|
||||
function upload_to_blob_store_public
|
||||
{
|
||||
upload_to_blob_store_with_acl "$1" "$2" "$3" "x-amz-acl:public-read"
|
||||
}
|
||||
|
||||
function upload_to_blob_store
|
||||
{
|
||||
upload_to_blob_store_with_acl "$1" "$2" "$3" "x-amz-acl:private"
|
||||
}
|
||||
55
script/lib/bump-version.sh
Executable file
55
script/lib/bump-version.sh
Executable file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -eu
|
||||
|
||||
package=$1
|
||||
tag_prefix=$2
|
||||
tag_suffix=$3
|
||||
version_increment=$4
|
||||
gpui_release=${5:-false}
|
||||
|
||||
if [[ -n $(git status --short --untracked-files=no) ]]; then
|
||||
echo "can't bump version with uncommitted changes"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
which cargo-set-version > /dev/null || cargo install cargo-edit
|
||||
which jq > /dev/null || brew install jq
|
||||
cargo set-version --package $package --bump $version_increment
|
||||
cargo check --quiet
|
||||
|
||||
new_version=$(script/get-crate-version $package)
|
||||
branch_name=$(git rev-parse --abbrev-ref HEAD)
|
||||
old_sha=$(git rev-parse HEAD)
|
||||
tag_name=${tag_prefix}${new_version}${tag_suffix}
|
||||
|
||||
git commit --quiet --all --message "${package} ${new_version}"
|
||||
git tag ${tag_name}
|
||||
|
||||
if [[ "$gpui_release" == "true" ]]; then
|
||||
cat <<MESSAGE
|
||||
Locally committed and tagged ${package} version ${new_version}
|
||||
|
||||
To push this:
|
||||
|
||||
git push origin ${tag_name} ${branch_name}; gh pr create -H ${branch_name}
|
||||
|
||||
To undo this:
|
||||
|
||||
git branch -D ${branch_name} && git tag -d ${tag_name}
|
||||
|
||||
MESSAGE
|
||||
else
|
||||
cat <<MESSAGE
|
||||
Locally committed and tagged ${package} version ${new_version}
|
||||
|
||||
To push this:
|
||||
|
||||
git push origin ${tag_name} ${branch_name}
|
||||
|
||||
To undo this:
|
||||
|
||||
git reset --hard ${old_sha} && git tag -d ${tag_name}
|
||||
|
||||
MESSAGE
|
||||
fi
|
||||
37
script/lib/deploy-helpers.sh
Normal file
37
script/lib/deploy-helpers.sh
Normal file
@@ -0,0 +1,37 @@
|
||||
function export_vars_for_environment {
|
||||
local environment=$1
|
||||
local env_file="crates/collab/k8s/environments/${environment}.sh"
|
||||
if [[ ! -f $env_file ]]; then
|
||||
echo "Invalid environment name '${environment}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
export $(grep -v '^#' $env_file | grep -v '^[[:space:]]*$')
|
||||
}
|
||||
|
||||
function target_zed_kube_cluster {
|
||||
if [[ $(kubectl config current-context 2> /dev/null) != do-nyc1-zed-1 ]]; then
|
||||
doctl kubernetes cluster kubeconfig save zed-1
|
||||
fi
|
||||
}
|
||||
|
||||
function tag_for_environment {
|
||||
if [[ "$1" == "production" ]]; then
|
||||
echo "collab-production"
|
||||
elif [[ "$1" == "staging" ]]; then
|
||||
echo "collab-staging"
|
||||
else
|
||||
echo "Invalid environment name '${environment}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function url_for_environment {
|
||||
if [[ "$1" == "production" ]]; then
|
||||
echo "https://collab.zed.dev"
|
||||
elif [[ "$1" == "staging" ]]; then
|
||||
echo "https://collab-staging.zed.dev"
|
||||
else
|
||||
echo "Invalid environment name '${environment}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
11
script/lib/squawk.toml
Normal file
11
script/lib/squawk.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
excluded_rules = [
|
||||
# We use `serial` already, no point changing now.
|
||||
"prefer-identity",
|
||||
|
||||
# We store timestamps in UTC, so we don't care about the timezone.
|
||||
"prefer-timestamptz",
|
||||
|
||||
"prefer-big-int",
|
||||
"prefer-bigint-over-int",
|
||||
]
|
||||
pg_version = "15.0"
|
||||
6
script/lib/workspace.ps1
Normal file
6
script/lib/workspace.ps1
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
function ParseZedWorkspace {
|
||||
$metadata = cargo metadata --no-deps --offline | ConvertFrom-Json
|
||||
$env:ZED_WORKSPACE = $metadata.workspace_root
|
||||
$env:RELEASE_VERSION = $metadata.packages | Where-Object { $_.name -eq "zed" } | Select-Object -ExpandProperty version
|
||||
}
|
||||
6
script/licenses/template.csv.hbs
Normal file
6
script/licenses/template.csv.hbs
Normal file
@@ -0,0 +1,6 @@
|
||||
Crate Name,Crate Version,License,Url
|
||||
{{#each licenses}}
|
||||
{{#each used_by}}
|
||||
{{crate.name}},{{crate.version}},{{../name}},{{#if crate.repository}}{{crate.repository}}{{else}}https://crates.io/crates/{{crate.name}}{{/if}}
|
||||
{{/each}}
|
||||
{{/each}}
|
||||
51
script/licenses/template.md.hbs
Normal file
51
script/licenses/template.md.hbs
Normal file
@@ -0,0 +1,51 @@
|
||||
## Overview of licenses:
|
||||
|
||||
{{#each overview}}
|
||||
* {{name}} ({{count}})
|
||||
{{/each}}
|
||||
|
||||
### All license texts:
|
||||
{{#each licenses}}
|
||||
|
||||
#### {{name}}
|
||||
|
||||
##### Used by:
|
||||
|
||||
{{#each used_by}}
|
||||
* [{{crate.name}} {{crate.version}}]({{#if crate.repository}} {{crate.repository}} {{else}} https://crates.io/crates/{{crate.name}} {{/if}})
|
||||
{{/each}}
|
||||
|
||||
{{text}}
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
{{/each}}
|
||||
|
||||
#### MIT License
|
||||
|
||||
##### Used by:
|
||||
|
||||
* [Windows Terminal]( https://github.com/microsoft/terminal )
|
||||
|
||||
Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
|
||||
MIT License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
48
script/licenses/zed-licenses.toml
Normal file
48
script/licenses/zed-licenses.toml
Normal file
@@ -0,0 +1,48 @@
|
||||
no-clearly-defined = true
|
||||
private = { ignore = true }
|
||||
# Licenses allowed in Zed's dependencies. AGPL should not be added to
|
||||
# this list as use of AGPL software is sometimes disallowed. When
|
||||
# adding to this list, please check the following open source license
|
||||
# policies:
|
||||
#
|
||||
# * https://opensource.google/documentation/reference/thirdparty/licenses
|
||||
#
|
||||
# The Zed project does have AGPL crates, but these are only involved
|
||||
# in servers and are not built into the binaries in the release
|
||||
# tarball. `script/check-licenses` checks that AGPL crates are not
|
||||
# involved in release binaries.
|
||||
accepted = [
|
||||
"Apache-2.0",
|
||||
"MIT",
|
||||
"MIT-0",
|
||||
"Apache-2.0 WITH LLVM-exception",
|
||||
"MPL-2.0",
|
||||
"BSD-3-Clause",
|
||||
"BSD-2-Clause",
|
||||
"ISC",
|
||||
"CC0-1.0",
|
||||
"NCSA",
|
||||
"Unicode-3.0",
|
||||
"OpenSSL",
|
||||
"Zlib",
|
||||
"BSL-1.0",
|
||||
"bzip2-1.0.6",
|
||||
]
|
||||
|
||||
[procinfo.clarify]
|
||||
license = "MIT"
|
||||
[[procinfo.clarify.files]]
|
||||
path = 'LICENSE.md'
|
||||
checksum = '37db33bbbd7348969eda397b89a16f252d56c1ca7481b6ccaf56ccdcbab5dcca'
|
||||
|
||||
[webpki.clarify]
|
||||
license = "ISC" # It actually says 'ISC-style' but I don't know the SPDX expression for that.
|
||||
[[webpki.clarify.files]]
|
||||
path = 'LICENSE'
|
||||
checksum = '5b698ca13897be3afdb7174256fa1574f8c6892b8bea1a66dd6469d3fe27885a'
|
||||
|
||||
[fuchsia-cprng.clarify]
|
||||
license = "BSD-3-Clause"
|
||||
[[fuchsia-cprng.clarify.files]]
|
||||
path = 'LICENSE'
|
||||
checksum = '03b114f53e6587a398931762ee11e2395bfdba252a329940e2c8c9e81813845b'
|
||||
283
script/linux
Executable file
283
script/linux
Executable file
@@ -0,0 +1,283 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -xeuo pipefail
|
||||
|
||||
# if root or if sudo/unavailable, define an empty variable
|
||||
if [ "$(id -u)" -eq 0 ]
|
||||
then maysudo=''
|
||||
else maysudo="$(command -v sudo || command -v doas || true)"
|
||||
fi
|
||||
|
||||
function finalize {
|
||||
# after packages install (curl, etc), get the rust toolchain
|
||||
which rustup > /dev/null 2>&1 || curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
cat <<EOF
|
||||
Finished installing Linux dependencies with script/linux
|
||||
EOF
|
||||
}
|
||||
|
||||
# Ubuntu, Debian, Mint, Kali, Pop!_OS, Raspbian, etc.
|
||||
apt=$(command -v apt-get || true)
|
||||
if [[ -n $apt ]]; then
|
||||
deps=(
|
||||
gcc
|
||||
g++
|
||||
libasound2-dev
|
||||
libfontconfig-dev
|
||||
libgit2-dev
|
||||
libglib2.0-dev
|
||||
libssl-dev
|
||||
libva-dev
|
||||
libvulkan1
|
||||
libwayland-dev
|
||||
libx11-xcb-dev
|
||||
libxkbcommon-x11-dev
|
||||
libzstd-dev
|
||||
make
|
||||
cmake
|
||||
clang
|
||||
lld
|
||||
llvm
|
||||
jq
|
||||
git
|
||||
curl
|
||||
gettext-base
|
||||
elfutils
|
||||
libsqlite3-dev
|
||||
musl-tools
|
||||
musl-dev
|
||||
build-essential
|
||||
pipewire
|
||||
xdg-desktop-portal
|
||||
)
|
||||
if (grep -qP 'PRETTY_NAME="(Debian|Raspbian).+13' /etc/os-release); then
|
||||
# libstdc++-14-dev is in build-essential
|
||||
true
|
||||
elif (grep -qP 'PRETTY_NAME="(Linux Mint 22|.+24\.(04|10))' /etc/os-release); then
|
||||
deps+=( libstdc++-14-dev )
|
||||
elif (grep -qP 'PRETTY_NAME="((Debian|Raspbian).+12|Linux Mint 21|.+22\.04)' /etc/os-release); then
|
||||
deps+=( libstdc++-12-dev )
|
||||
elif (grep -qP 'PRETTY_NAME="((Debian|Raspbian).+11|Linux Mint 20|.+20\.04)' /etc/os-release); then
|
||||
# Ubuntu 20.04 ships clang-10 and libstdc++-10 which lack adequate C++20
|
||||
# support for building webrtc-sys (requires -std=c++20, lambdas in
|
||||
# unevaluated contexts from clang 17+, and working std::ranges in the
|
||||
# stdlib).
|
||||
# Note: the prebuilt libwebrtc.a is compiled with libstdc++, so we must
|
||||
# use libstdc++ (not libc++) to avoid ABI mismatches at link time.
|
||||
|
||||
# libstdc++-11-dev (headers with working pointer_traits/contiguous_range)
|
||||
# is only available from the ubuntu-toolchain-r PPA. Add the source list
|
||||
# and GPG key manually instead of using add-apt-repository, whose HKP
|
||||
# keyserver lookups (port 11371) frequently time out in CI.
|
||||
$maysudo "$apt" install -y curl gnupg
|
||||
codename=$(lsb_release -cs)
|
||||
echo "deb https://ppa.launchpadcontent.net/ubuntu-toolchain-r/test/ubuntu $codename main" | \
|
||||
$maysudo tee /etc/apt/sources.list.d/ubuntu-toolchain-r-test.list > /dev/null
|
||||
curl -fsSL 'https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x1E9377A2BA9EF27F' | \
|
||||
sed -n '/-----BEGIN PGP PUBLIC KEY BLOCK-----/,/-----END PGP PUBLIC KEY BLOCK-----/p' | \
|
||||
$maysudo gpg --dearmor -o /etc/apt/trusted.gpg.d/ubuntu-toolchain-r-test.gpg
|
||||
deps+=( clang-18 libstdc++-11-dev )
|
||||
fi
|
||||
|
||||
$maysudo "$apt" update
|
||||
$maysudo "$apt" install -y "${deps[@]}"
|
||||
|
||||
finalize
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Fedora, CentOS, RHEL, Alma, Amazon 2023, Oracle, etc.
|
||||
dnf=$(command -v dnf || true)
|
||||
# Old Redhat (yum only): Amazon Linux 2, Oracle Linux 7, etc.
|
||||
yum=$(command -v yum || true)
|
||||
|
||||
if [[ -n $dnf ]] || [[ -n $yum ]]; then
|
||||
pkg_cmd="${dnf:-${yum}}"
|
||||
deps=(
|
||||
musl-gcc
|
||||
gcc
|
||||
clang
|
||||
cmake
|
||||
alsa-lib-devel
|
||||
fontconfig-devel
|
||||
glib2-devel
|
||||
libva-devel
|
||||
wayland-devel
|
||||
libxcb-devel
|
||||
libxkbcommon-x11-devel
|
||||
openssl-devel
|
||||
libzstd-devel
|
||||
vulkan-loader
|
||||
sqlite-devel
|
||||
pipewire
|
||||
xdg-desktop-portal
|
||||
jq
|
||||
git
|
||||
tar
|
||||
)
|
||||
# perl used for building openssl-sys crate. See: https://docs.rs/openssl/latest/openssl/
|
||||
if grep -qP '^ID="?(fedora)' /etc/os-release; then
|
||||
deps+=(
|
||||
perl-FindBin
|
||||
perl-IPC-Cmd
|
||||
perl-File-Compare
|
||||
perl-File-Copy
|
||||
)
|
||||
elif grep -qP '^ID="?(rhel|rocky|alma|centos|ol)' /etc/os-release; then
|
||||
deps+=( perl-interpreter )
|
||||
fi
|
||||
|
||||
# gcc-c++ is g++ on RHEL8 and 8.x clones
|
||||
if grep -qP '^ID="?(rhel|rocky|alma|centos|ol)' /etc/os-release \
|
||||
&& grep -qP '^VERSION_ID="?(8)' /etc/os-release; then
|
||||
deps+=( gcc-c++ )
|
||||
else
|
||||
deps+=( g++ )
|
||||
fi
|
||||
|
||||
# libxkbcommon-x11-devel is in a non-default repo on RHEL 8.x/9.x (except on AmazonLinux)
|
||||
if grep -qP '^VERSION_ID="?(8|9)' /etc/os-release && grep -qP '^ID="?(rhel|rocky|centos|alma|ol)' /etc/os-release; then
|
||||
$maysudo dnf install -y 'dnf-command(config-manager)'
|
||||
if grep -qP '^PRETTY_NAME="(AlmaLinux 8|Rocky Linux 8)' /etc/os-release; then
|
||||
$maysudo dnf config-manager --set-enabled powertools
|
||||
elif grep -qP '^PRETTY_NAME="((AlmaLinux|Rocky|CentOS Stream) 9|Red Hat.+(8|9))' /etc/os-release; then
|
||||
$maysudo dnf config-manager --set-enabled crb
|
||||
elif grep -qP '^PRETTY_NAME="Oracle Linux Server 8' /etc/os-release; then
|
||||
$maysudo dnf config-manager --set-enabled ol8_codeready_builder
|
||||
elif grep -qP '^PRETTY_NAME="Oracle Linux Server 9' /etc/os-release; then
|
||||
$maysudo dnf config-manager --set-enabled ol9_codeready_builder
|
||||
else
|
||||
echo "Unexpected distro" && grep 'PRETTY_NAME' /etc/os-release && exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
$maysudo "$pkg_cmd" install -y "${deps[@]}"
|
||||
finalize
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# openSUSE
|
||||
# https://software.opensuse.org/
|
||||
zyp=$(command -v zypper || true)
|
||||
if [[ -n $zyp ]]; then
|
||||
deps=(
|
||||
alsa-devel
|
||||
clang
|
||||
cmake
|
||||
fontconfig-devel
|
||||
gcc
|
||||
libva-devel
|
||||
gcc-c++
|
||||
glib2-devel
|
||||
git
|
||||
gzip
|
||||
jq
|
||||
libvulkan1
|
||||
libx11-devel
|
||||
libxcb-devel
|
||||
libxkbcommon-devel
|
||||
libxkbcommon-x11-devel
|
||||
libzstd-devel
|
||||
make
|
||||
openssl-devel
|
||||
sqlite3-devel
|
||||
tar
|
||||
wayland-devel
|
||||
xcb-util-devel
|
||||
pipewire
|
||||
xdg-desktop-portal
|
||||
)
|
||||
$maysudo "$zyp" install -y "${deps[@]}"
|
||||
finalize
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Arch, Manjaro, etc.
|
||||
# https://archlinux.org/packages
|
||||
pacman=$(command -v pacman || true)
|
||||
if [[ -n $pacman ]]; then
|
||||
deps=(
|
||||
gcc
|
||||
clang
|
||||
musl
|
||||
cmake
|
||||
alsa-lib
|
||||
fontconfig
|
||||
glib2
|
||||
libva
|
||||
wayland
|
||||
libgit2
|
||||
libxcb
|
||||
libxkbcommon-x11
|
||||
openssl
|
||||
zstd
|
||||
pkgconf
|
||||
sqlite
|
||||
pipewire
|
||||
xdg-desktop-portal
|
||||
jq
|
||||
git
|
||||
)
|
||||
$maysudo "$pacman" -Syu --needed --noconfirm "${deps[@]}"
|
||||
finalize
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Void
|
||||
# https://voidlinux.org/packages/
|
||||
xbps=$(command -v xbps-install || true)
|
||||
if [[ -n $xbps ]]; then
|
||||
deps=(
|
||||
gettext-devel
|
||||
clang
|
||||
cmake
|
||||
jq
|
||||
elfutils-devel
|
||||
gcc
|
||||
alsa-lib-devel
|
||||
fontconfig-devel
|
||||
glib-devel
|
||||
libva-devel
|
||||
libxcb-devel
|
||||
libxkbcommon-devel
|
||||
libzstd-devel
|
||||
openssl-devel
|
||||
wayland-devel
|
||||
vulkan-loader
|
||||
sqlite-devel
|
||||
pipewire
|
||||
xdg-desktop-portal
|
||||
)
|
||||
$maysudo "$xbps" -Syu "${deps[@]}"
|
||||
finalize
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Gentoo
|
||||
# https://packages.gentoo.org/
|
||||
emerge=$(command -v emerge || true)
|
||||
if [[ -n $emerge ]]; then
|
||||
deps=(
|
||||
app-arch/zstd
|
||||
app-misc/jq
|
||||
dev-libs/glib
|
||||
dev-libs/openssl
|
||||
dev-libs/wayland
|
||||
dev-util/cmake
|
||||
media-libs/alsa-lib
|
||||
media-libs/fontconfig
|
||||
media-libs/libva
|
||||
media-libs/vulkan-loader
|
||||
x11-libs/libxcb
|
||||
x11-libs/libxkbcommon
|
||||
dev-db/sqlite
|
||||
media-video/pipewire
|
||||
sys-apps/xdg-desktop-portal
|
||||
)
|
||||
$maysudo "$emerge" -u "${deps[@]}"
|
||||
finalize
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Unsupported Linux distribution in script/linux"
|
||||
exit 1
|
||||
13
script/metal-debug
Executable file
13
script/metal-debug
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
export GPUTOOLS_LOAD_GTMTLCAPTURE=1
|
||||
export DYLD_LIBRARY_PATH="/usr/lib/system/introspection"
|
||||
export METAL_LOAD_INTERPOSER=1
|
||||
export DYLD_INSERT_LIBRARIES="/usr/lib/libMTLCapture.dylib"
|
||||
export DYMTL_TOOLS_DYLIB_PATH="/usr/lib/libMTLCapture.dylib"
|
||||
export METAL_DEVICE_WRAPPER_TYPE=1
|
||||
export GPUProfilerEnabled="YES"
|
||||
export METAL_DEBUG_ERROR_MODE=0
|
||||
export LD_LIBRARY_PATH="/Applications/Xcode.app/Contents/Developer/../SharedFrameworks/"
|
||||
|
||||
cargo run "$@"
|
||||
30
script/mitm-proxy.sh
Executable file
30
script/mitm-proxy.sh
Executable file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
ENGINE="docker"
|
||||
elif command -v podman >/dev/null 2>&1; then
|
||||
ENGINE="podman"
|
||||
else
|
||||
echo "Neither Docker nor Podman found. Please install one of them."
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -d ~/.mitmproxy ]; then
|
||||
mkdir -p ~/.mitmproxy
|
||||
fi
|
||||
|
||||
CONTAINER_ID="$(${ENGINE} run -d --rm -it -v ~/.mitmproxy:/home/mitmproxy/.mitmproxy -p 9876:8080 mitmproxy/mitmproxy mitmdump)"
|
||||
|
||||
trap "${ENGINE} stop \"$CONTAINER_ID\" 1> /dev/null || true; exit 1" SIGINT
|
||||
|
||||
echo "Add the root certificate created in ~/.mitmproxy to your certificate chain for HTTP"
|
||||
echo "on macOS:"
|
||||
echo "sudo security add-trusted-cert -d -p ssl -p basic -k /Library/Keychains/System.keychain ~/.mitmproxy/mitmproxy-ca-cert.pem"
|
||||
echo "Press enter to continue"
|
||||
read
|
||||
|
||||
http_proxy=http://localhost:9876 cargo run
|
||||
|
||||
# Clean up detached proxy after running
|
||||
${ENGINE} stop "${CONTAINER_ID}" 2>/dev/null || true
|
||||
86
script/new-crate
Executable file
86
script/new-crate
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Try to make sure we are in the zed repo root
|
||||
if [ ! -d "crates" ] || [ ! -d "script" ]; then
|
||||
echo "Error: Run from the \`zed\` repo root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "Cargo.toml" ]; then
|
||||
echo "Error: Run from the \`zed\` repo root"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: $0 <crate_name> [optional_license_flag]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CRATE_NAME="$1"
|
||||
|
||||
LICENSE_FLAG=$(echo "${2}" | tr '[:upper:]' '[:lower:]')
|
||||
if [[ "$LICENSE_FLAG" == *"apache"* ]]; then
|
||||
LICENSE_MODE="Apache-2.0"
|
||||
LICENSE_FILE="LICENSE-APACHE"
|
||||
elif [[ "$LICENSE_FLAG" == *"agpl"* ]]; then
|
||||
LICENSE_MODE="AGPL-3.0-or-later"
|
||||
LICENSE_FILE="LICENSE-AGPL"
|
||||
else
|
||||
LICENSE_MODE="GPL-3.0-or-later"
|
||||
LICENSE_FILE="LICENSE-GPL"
|
||||
fi
|
||||
|
||||
if [[ ! "$CRATE_NAME" =~ ^[a-z0-9_]+$ ]]; then
|
||||
echo "Error: Crate name must be lowercase and contain only alphanumeric characters and underscores"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CRATE_PATH="crates/$CRATE_NAME"
|
||||
mkdir -p "$CRATE_PATH/src"
|
||||
|
||||
# Symlink the license
|
||||
ln -sf "../../$LICENSE_FILE" "$CRATE_PATH/$LICENSE_FILE"
|
||||
|
||||
CARGO_TOML_TEMPLATE=$(cat << 'EOF'
|
||||
[package]
|
||||
name = "$CRATE_NAME"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
license = "$LICENSE_MODE"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[lib]
|
||||
path = "src/$CRATE_NAME.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
gpui.workspace = true
|
||||
ui.workspace = true
|
||||
util.workspace = true
|
||||
|
||||
# Uncomment other workspace dependencies as needed
|
||||
# assistant.workspace = true
|
||||
# client.workspace = true
|
||||
# project.workspace = true
|
||||
# settings.workspace = true
|
||||
EOF
|
||||
)
|
||||
|
||||
# Populate template
|
||||
CARGO_TOML_CONTENT=$(echo "$CARGO_TOML_TEMPLATE" | sed \
|
||||
-e "s/\$CRATE_NAME/$CRATE_NAME/g" \
|
||||
-e "s/\$LICENSE_MODE/$LICENSE_MODE/g")
|
||||
|
||||
echo "$CARGO_TOML_CONTENT" > "$CRATE_PATH/Cargo.toml"
|
||||
|
||||
echo "//! # $CRATE_NAME" > "$CRATE_PATH/src/$CRATE_NAME.rs"
|
||||
|
||||
echo "Created new crate: $CRATE_NAME in $CRATE_PATH"
|
||||
echo "License: $LICENSE_MODE (symlinked from $LICENSE_FILE)"
|
||||
echo "Don't forget to add the new crate to the workspace!"
|
||||
23
script/prettier
Executable file
23
script/prettier
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/bin/bash
|
||||
set -euxo pipefail
|
||||
|
||||
PRETTIER_VERSION=3.5.0
|
||||
|
||||
if [[ "${1:-}" == "--write" ]]; then
|
||||
MODE="--write"
|
||||
else
|
||||
MODE="--check"
|
||||
fi
|
||||
|
||||
pnpm dlx "prettier@${PRETTIER_VERSION}" assets/settings/default.json --parser=jsonc $MODE || {
|
||||
echo "To fix, run from the root of the Zed repo:"
|
||||
echo " pnpm dlx prettier@${PRETTIER_VERSION} assets/settings/default.json --parser=jsonc --write"
|
||||
false
|
||||
}
|
||||
|
||||
cd docs
|
||||
pnpm dlx "prettier@${PRETTIER_VERSION}" . $MODE || {
|
||||
echo "To fix, run from the root of the Zed repo:"
|
||||
echo " cd docs && pnpm dlx prettier@${PRETTIER_VERSION} . --write && cd .."
|
||||
false
|
||||
}
|
||||
61
script/prompts
Executable file
61
script/prompts
Executable file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# This script manages prompt overrides for the Zed editor.
|
||||
#
|
||||
# It provides functionality to:
|
||||
# 1. Link the current repository's prompt templates to Zed's configuration.
|
||||
# 2. Create and link a separate Git worktree for prompt management.
|
||||
# 3. Unlink previously linked prompt overrides.
|
||||
#
|
||||
# Usage:
|
||||
# ./script_name.sh link # Link current repo's prompts
|
||||
# ./script_name.sh link --worktree # Create and link a separate worktree
|
||||
# ./script_name.sh unlink # Remove existing prompt override link
|
||||
#
|
||||
# The script ensures proper Git branch and worktree setup when using the
|
||||
# --worktree option. It also provides informative output and error handling.
|
||||
|
||||
if [ "$1" = "link" ]; then
|
||||
# Remove existing link (or directory)
|
||||
rm -rf ~/.config/zed/prompt_overrides
|
||||
if [ "$2" = "--worktree" ]; then
|
||||
# Check if 'prompts' branch exists, create if not
|
||||
if ! git show-ref --quiet refs/heads/prompts; then
|
||||
git branch prompts
|
||||
fi
|
||||
# Check if 'prompts' worktree exists
|
||||
if git worktree list | grep -q "../zed_prompts"; then
|
||||
echo "Worktree already exists at ../zed_prompts."
|
||||
else
|
||||
# Create worktree if it doesn't exist
|
||||
git worktree add ../zed_prompts prompts || git worktree add ../zed_prompts -b prompts
|
||||
fi
|
||||
ln -sf "$(realpath "$(pwd)/../zed_prompts/assets/prompts")" ~/.config/zed/prompt_overrides
|
||||
echo "Linked $(realpath "$(pwd)/../zed_prompts/assets/prompts") to ~/.config/zed/prompt_overrides"
|
||||
echo -e "\033[0;33mDon't forget you have it linked, or your prompts will go stale\033[0m"
|
||||
else
|
||||
ln -sf "$(pwd)/assets/prompts" ~/.config/zed/prompt_overrides
|
||||
echo "Linked $(pwd)/assets/prompts to ~/.config/zed/prompt_overrides"
|
||||
fi
|
||||
elif [ "$1" = "unlink" ]; then
|
||||
if [ -e ~/.config/zed/prompt_overrides ]; then
|
||||
# Remove symbolic link
|
||||
rm -rf ~/.config/zed/prompt_overrides
|
||||
echo "Unlinked ~/.config/zed/prompt_overrides"
|
||||
else
|
||||
echo -e "\033[33mWarning: No file exists at ~/.config/zed/prompt_overrides\033[0m"
|
||||
fi
|
||||
else
|
||||
echo "This script helps you manage prompt overrides for Zed."
|
||||
echo "You can link this directory to have Zed use the contents of your current repo templates as your active prompts,"
|
||||
echo "or store your modifications in a separate Git worktree."
|
||||
echo
|
||||
echo "Usage: $0 [link [--worktree]|unlink]"
|
||||
echo
|
||||
echo "Options:"
|
||||
echo " link Create a symbolic link from ./assets/prompts to ~/.config/zed/prompt_overrides"
|
||||
echo " link --worktree Create a 'prompts' Git worktree in ../prompts, then link ../prompts/assets/prompts"
|
||||
echo " to ~/.config/zed/prompt_overrides"
|
||||
echo " unlink Remove the symbolic link at ~/.config/zed/prompt_overrides"
|
||||
exit 1
|
||||
fi
|
||||
70
script/randomized-test-ci
Executable file
70
script/randomized-test-ci
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node --redirect-warnings=/dev/null
|
||||
|
||||
const fs = require("fs");
|
||||
const { randomBytes } = require("crypto");
|
||||
const { execFileSync } = require("child_process");
|
||||
const {
|
||||
minimizeTestPlan,
|
||||
buildTests,
|
||||
runTests,
|
||||
} = require("./randomized-test-minimize");
|
||||
|
||||
const { ZED_SERVER_URL } = process.env;
|
||||
if (!ZED_SERVER_URL) throw new Error("Missing env var `ZED_SERVER_URL`");
|
||||
|
||||
main();
|
||||
|
||||
async function main() {
|
||||
buildTests();
|
||||
|
||||
const seed = randomU64();
|
||||
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
|
||||
console.log("commit:", commit);
|
||||
console.log("starting seed:", seed);
|
||||
|
||||
const planPath = "target/test-plan.json";
|
||||
const minPlanPath = "target/test-plan.min.json";
|
||||
const failingSeed = runTests({
|
||||
SEED: seed,
|
||||
SAVE_PLAN: planPath,
|
||||
ITERATIONS: 50000,
|
||||
OPERATIONS: 200,
|
||||
});
|
||||
|
||||
if (!failingSeed) {
|
||||
console.log("tests passed");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("found failure at seed", failingSeed);
|
||||
const minimizedSeed = minimizeTestPlan(planPath, minPlanPath);
|
||||
const minimizedPlan = fs.readFileSync(minPlanPath, "utf8");
|
||||
|
||||
console.log("minimized plan:\n", minimizedPlan);
|
||||
|
||||
const url = `${ZED_SERVER_URL}/api/randomized_test_failure`;
|
||||
const body = {
|
||||
seed: minimizedSeed,
|
||||
plan: JSON.parse(minimizedPlan),
|
||||
commit: commit,
|
||||
};
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function randomU64() {
|
||||
const bytes = randomBytes(8);
|
||||
const hexString = bytes.reduce(
|
||||
(string, byte) => string + byte.toString(16),
|
||||
"",
|
||||
);
|
||||
return BigInt("0x" + hexString).toString(10);
|
||||
}
|
||||
135
script/randomized-test-minimize
Executable file
135
script/randomized-test-minimize
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env node --redirect-warnings=/dev/null
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const FAILING_SEED_REGEX = /failing seed: (\d+)/gi;
|
||||
const CARGO_TEST_ARGS = ["--release", "--lib", "--package", "collab"];
|
||||
|
||||
if (require.main === module) {
|
||||
if (process.argv.length < 4) {
|
||||
process.stderr.write(
|
||||
"usage: script/randomized-test-minimize <input-plan> <output-plan> [start-index]\n",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
minimizeTestPlan(
|
||||
process.argv[2],
|
||||
process.argv[3],
|
||||
parseInt(process.argv[4]) || 0,
|
||||
);
|
||||
}
|
||||
|
||||
function minimizeTestPlan(inputPlanPath, outputPlanPath, startIndex = 0) {
|
||||
const tempPlanPath = inputPlanPath + ".try";
|
||||
|
||||
fs.copyFileSync(inputPlanPath, outputPlanPath);
|
||||
let testPlan = JSON.parse(fs.readFileSync(outputPlanPath, "utf8"));
|
||||
|
||||
process.stderr.write("minimizing failing test plan...\n");
|
||||
for (let ix = startIndex; ix < testPlan.length; ix++) {
|
||||
// Skip 'MutateClients' entries, since they themselves are not single operations.
|
||||
if (testPlan[ix].MutateClients) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Remove a row from the test plan
|
||||
const newTestPlan = testPlan.slice();
|
||||
newTestPlan.splice(ix, 1);
|
||||
fs.writeFileSync(tempPlanPath, serializeTestPlan(newTestPlan), "utf8");
|
||||
|
||||
process.stderr.write(
|
||||
`${ix}/${testPlan.length}: ${JSON.stringify(testPlan[ix])}`,
|
||||
);
|
||||
const failingSeed = runTests({
|
||||
SEED: "0",
|
||||
LOAD_PLAN: tempPlanPath,
|
||||
SAVE_PLAN: tempPlanPath,
|
||||
ITERATIONS: "500",
|
||||
});
|
||||
|
||||
// If the test failed, keep the test plan with the removed row. Reload the test
|
||||
// plan from the JSON file, since the test itself will remove any operations
|
||||
// which are no longer valid before saving the test plan.
|
||||
if (failingSeed != null) {
|
||||
process.stderr.write(` - remove. failing seed: ${failingSeed}.\n`);
|
||||
fs.copyFileSync(tempPlanPath, outputPlanPath);
|
||||
testPlan = JSON.parse(fs.readFileSync(outputPlanPath, "utf8"));
|
||||
ix--;
|
||||
} else {
|
||||
process.stderr.write(` - keep.\n`);
|
||||
}
|
||||
}
|
||||
|
||||
fs.unlinkSync(tempPlanPath);
|
||||
|
||||
// Re-run the final minimized plan to get the correct failing seed.
|
||||
// This is a workaround for the fact that the execution order can
|
||||
// slightly change when replaying a test plan after it has been
|
||||
// saved and loaded.
|
||||
const failingSeed = runTests({
|
||||
SEED: "0",
|
||||
ITERATIONS: "5000",
|
||||
LOAD_PLAN: outputPlanPath,
|
||||
});
|
||||
|
||||
process.stderr.write(`final test plan: ${outputPlanPath}\n`);
|
||||
process.stderr.write(`final seed: ${failingSeed}\n`);
|
||||
return failingSeed;
|
||||
}
|
||||
|
||||
function buildTests() {
|
||||
const { status } = spawnSync(
|
||||
"cargo",
|
||||
["test", "--no-run", ...CARGO_TEST_ARGS],
|
||||
{
|
||||
stdio: "inherit",
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (status !== 0) {
|
||||
throw new Error("build failed");
|
||||
}
|
||||
}
|
||||
|
||||
function runTests(env) {
|
||||
const { status, stdout } = spawnSync(
|
||||
"cargo",
|
||||
["test", ...CARGO_TEST_ARGS, "random_project_collaboration"],
|
||||
{
|
||||
stdio: "pipe",
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (status !== 0) {
|
||||
FAILING_SEED_REGEX.lastIndex = 0;
|
||||
const match = FAILING_SEED_REGEX.exec(stdout);
|
||||
if (!match) {
|
||||
process.stderr.write("test failed, but no failing seed found:\n");
|
||||
process.stderr.write(stdout);
|
||||
process.stderr.write("\n");
|
||||
process.exit(1);
|
||||
}
|
||||
return match[1];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeTestPlan(plan) {
|
||||
return "[\n" + plan.map((row) => JSON.stringify(row)).join(",\n") + "\n]\n";
|
||||
}
|
||||
|
||||
exports.buildTests = buildTests;
|
||||
exports.runTests = runTests;
|
||||
exports.minimizeTestPlan = minimizeTestPlan;
|
||||
39
script/redeploy-vercel
Executable file
39
script/redeploy-vercel
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${VERCEL_TOKEN:-}" ]]; then
|
||||
echo "Error: VERCEL_TOKEN environment variable is not set."
|
||||
echo "Get a token from https://vercel.com/account/tokens"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MAX_ATTEMPTS="60"
|
||||
SLEEP_SECONDS="10"
|
||||
VERCEL_SCOPE="zed-industries"
|
||||
VERCEL_URL="https://zed.dev"
|
||||
|
||||
echo "Checking for in-progress deployments..."
|
||||
|
||||
for ((i=1; i<=MAX_ATTEMPTS; i++)); do
|
||||
RESPONSE=$(curl -s -H "Authorization: Bearer $VERCEL_TOKEN" \
|
||||
"https://api.vercel.com/v6/deployments?slug=${VERCEL_SCOPE}&state=BUILDING,INITIALIZING,QUEUED&target=production&limit=1")
|
||||
|
||||
COUNT=$(echo "$RESPONSE" | jq '.deployments | length')
|
||||
|
||||
if [ "$COUNT" = "0" ]; then
|
||||
echo "No in-progress deployments found. Proceeding with redeploy."
|
||||
break
|
||||
fi
|
||||
|
||||
if [ "$i" = "$MAX_ATTEMPTS" ]; then
|
||||
echo "Timed out waiting for deployments to complete after $((MAX_ATTEMPTS * SLEEP_SECONDS)) seconds."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Attempt $i/$MAX_ATTEMPTS: Found $COUNT in-progress deployment(s). Waiting ${SLEEP_SECONDS}s..."
|
||||
sleep "$SLEEP_SECONDS"
|
||||
done
|
||||
|
||||
echo "Triggering redeploy of ${VERCEL_URL}..."
|
||||
npm exec --yes -- vercel@37 --token="$VERCEL_TOKEN" --scope "$VERCEL_SCOPE" redeploy "$VERCEL_URL"
|
||||
17
script/remote-server
Executable file
17
script/remote-server
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -xeuo pipefail
|
||||
|
||||
# if root or if sudo/unavailable, define an empty variable
|
||||
if [ "$(id -u)" -eq 0 ]
|
||||
then maysudo=''
|
||||
else maysudo="$(command -v sudo || command -v doas || true)"
|
||||
fi
|
||||
|
||||
deps=(
|
||||
clang
|
||||
)
|
||||
|
||||
$maysudo apt-get update
|
||||
$maysudo apt-get install -y "${deps[@]}"
|
||||
exit 0
|
||||
3
script/reset_db
Executable file
3
script/reset_db
Executable file
@@ -0,0 +1,3 @@
|
||||
psql postgres -c "DROP DATABASE zed WITH (FORCE);"
|
||||
psql postgres -c "DROP DATABASE zed_llm WITH (FORCE);"
|
||||
script/bootstrap
|
||||
264
script/run-background-agent-mvp-local
Executable file
264
script/run-background-agent-mvp-local
Executable file
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run the background crash-agent MVP pipeline locally.
|
||||
|
||||
Default mode is dry-run: generate branch + agent output without commit/push/PR.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
CRASH_ID_PATTERN = re.compile(r"^[A-Za-z0-9]+-[A-Za-z0-9]+$")
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def run(command: list[str], check: bool = True, capture_output: bool = False) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
command,
|
||||
cwd=REPO_ROOT,
|
||||
text=True,
|
||||
check=check,
|
||||
capture_output=capture_output,
|
||||
)
|
||||
|
||||
|
||||
def validate_crash_ids(ids: list[str]) -> list[str]:
|
||||
valid = []
|
||||
for crash_id in ids:
|
||||
if not CRASH_ID_PATTERN.match(crash_id):
|
||||
print(f"WARNING: Skipping invalid crash ID format: '{crash_id}'", file=sys.stderr)
|
||||
continue
|
||||
valid.append(crash_id)
|
||||
return valid
|
||||
|
||||
|
||||
MAX_TOP = 100
|
||||
|
||||
|
||||
def prefetch_crash_data(crashes: list[dict[str, str]], output_dir: str) -> None:
|
||||
"""Fetch crash reports and save to output_dir/crash-{ID}.md.
|
||||
|
||||
Each crash item must contain:
|
||||
- crash_id: short ID used by the pipeline (e.g. ZED-202)
|
||||
- fetch_id: identifier passed to script/sentry-fetch (short or numeric)
|
||||
"""
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
for crash in crashes:
|
||||
crash_id = crash["crash_id"]
|
||||
fetch_id = crash["fetch_id"]
|
||||
output_path = os.path.join(output_dir, f"crash-{crash_id}.md")
|
||||
result = run(
|
||||
["python3", "script/sentry-fetch", fetch_id],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(
|
||||
f"WARNING: Failed to fetch crash data for {crash_id} "
|
||||
f"(fetch id: {fetch_id}): {result.stderr.strip()}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(result.stdout)
|
||||
print(
|
||||
f"Fetched crash data for {crash_id} (fetch id: {fetch_id}) -> {output_path}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
|
||||
def resolve_crashes(args) -> list[dict[str, str]]:
|
||||
if args.crash_ids:
|
||||
raw = [item.strip() for item in args.crash_ids.split(",") if item.strip()]
|
||||
crash_ids = validate_crash_ids(raw)
|
||||
return [{"crash_id": crash_id, "fetch_id": crash_id} for crash_id in crash_ids]
|
||||
|
||||
top = min(args.top, MAX_TOP)
|
||||
if args.top > MAX_TOP:
|
||||
print(f"Capping --top from {args.top} to {MAX_TOP}", file=sys.stderr)
|
||||
|
||||
with tempfile.NamedTemporaryFile(mode="w+", suffix=".json", delete=False) as file:
|
||||
output_path = file.name
|
||||
|
||||
try:
|
||||
run(
|
||||
[
|
||||
"python3",
|
||||
"script/select-sentry-crash-candidates",
|
||||
"--org",
|
||||
args.org,
|
||||
"--top",
|
||||
str(top),
|
||||
"--sample-size",
|
||||
str(args.sample_size),
|
||||
"--output",
|
||||
output_path,
|
||||
],
|
||||
capture_output=True,
|
||||
)
|
||||
with open(output_path, "r", encoding="utf-8") as file:
|
||||
payload = json.load(file)
|
||||
crashes = []
|
||||
for item in payload.get("selected", []):
|
||||
crash_id = item.get("short_id")
|
||||
issue_id = item.get("issue_id")
|
||||
if not crash_id:
|
||||
continue
|
||||
crashes.append(
|
||||
{
|
||||
"crash_id": crash_id,
|
||||
"fetch_id": str(issue_id) if issue_id else crash_id,
|
||||
}
|
||||
)
|
||||
|
||||
valid_crash_ids = set(validate_crash_ids([item["crash_id"] for item in crashes]))
|
||||
return [item for item in crashes if item["crash_id"] in valid_crash_ids]
|
||||
finally:
|
||||
try:
|
||||
os.remove(output_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def write_prompt(crash_id: str, crash_data_file: str | None = None) -> Path:
|
||||
if crash_data_file:
|
||||
fetch_step = (
|
||||
f"The crash report has been pre-fetched and is available at: {crash_data_file}\n"
|
||||
f"Read this file to get the crash data. Do not call script/sentry-fetch.\n"
|
||||
f"\n"
|
||||
f"1. Read the crash report from {crash_data_file}"
|
||||
)
|
||||
else:
|
||||
fetch_step = f"1. Fetch crash data with: script/sentry-fetch {crash_id}"
|
||||
|
||||
prompt = f"""You are running the weekly background crash-fix MVP pipeline for crash {crash_id}.
|
||||
|
||||
Required workflow:
|
||||
{fetch_step}
|
||||
2. Read and follow `.rules`.
|
||||
3. Follow .factory/prompts/crash/investigate.md and write ANALYSIS.md
|
||||
4. Follow .factory/prompts/crash/link-issues.md and write LINKED_ISSUES.md
|
||||
5. Follow .factory/prompts/crash/fix.md to implement a minimal fix with tests
|
||||
6. Run validators required by the fix prompt for the affected code paths
|
||||
7. Write PR_BODY.md with sections:
|
||||
- Crash Summary
|
||||
- Root Cause
|
||||
- Fix
|
||||
- Validation
|
||||
- Potentially Related Issues (High/Medium/Low from LINKED_ISSUES.md)
|
||||
- Reviewer Checklist
|
||||
- Release Notes (final section, formatted as "Release Notes:", blank line, then one bullet like "- N/A" when not user-facing)
|
||||
|
||||
Constraints:
|
||||
- Keep changes narrowly scoped to this crash.
|
||||
- If the crash is not solvable with available context, write a clear blocker summary to PR_BODY.md.
|
||||
"""
|
||||
|
||||
file_path = Path(tempfile.gettempdir()) / f"background-agent-{crash_id}.md"
|
||||
file_path.write_text(prompt, encoding="utf-8")
|
||||
return file_path
|
||||
|
||||
|
||||
def run_pipeline_for_crash(args, crash_id: str) -> dict:
|
||||
branch = f"background-agent/mvp-{crash_id.lower()}-{datetime.utcnow().strftime('%Y%m%d')}"
|
||||
|
||||
if args.reset_branch:
|
||||
run(["git", "fetch", "origin", "main"], check=False)
|
||||
run(["git", "checkout", "-B", branch, "origin/main"])
|
||||
|
||||
prompt_file = write_prompt(crash_id)
|
||||
try:
|
||||
droid_command = [
|
||||
args.droid_bin,
|
||||
"exec",
|
||||
"--auto",
|
||||
"medium",
|
||||
"-m",
|
||||
args.model,
|
||||
"-f",
|
||||
str(prompt_file),
|
||||
]
|
||||
completed = run(droid_command, check=False)
|
||||
|
||||
has_changes = run(["git", "diff", "--quiet"], check=False).returncode != 0
|
||||
return {
|
||||
"crash_id": crash_id,
|
||||
"branch": branch,
|
||||
"droid_exit_code": completed.returncode,
|
||||
"has_changes": has_changes,
|
||||
}
|
||||
finally:
|
||||
try:
|
||||
prompt_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Run local background-agent MVP dry-run workflow")
|
||||
parser.add_argument("--crash-ids", help="Comma-separated crash IDs (e.g. ZED-4VS,ZED-123)")
|
||||
parser.add_argument("--top", type=int, default=3, help="Top N crashes when --crash-ids is omitted (max 100)")
|
||||
parser.add_argument(
|
||||
"--sample-size",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of unresolved issues to consider for candidate selection",
|
||||
)
|
||||
parser.add_argument("--org", default="zed-dev", help="Sentry org slug")
|
||||
parser.add_argument(
|
||||
"--select-only",
|
||||
action="store_true",
|
||||
help="Resolve crash IDs and print them comma-separated, then exit. No agent execution.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--prefetch-dir",
|
||||
help="When used with --select-only, also fetch crash data via script/sentry-fetch "
|
||||
"and save reports to DIR/crash-{ID}.md for each resolved ID.",
|
||||
)
|
||||
parser.add_argument("--model", default=os.environ.get("DROID_MODEL", "claude-opus-4-5-20251101"))
|
||||
parser.add_argument("--droid-bin", default=os.environ.get("DROID_BIN", "droid"))
|
||||
parser.add_argument(
|
||||
"--reset-branch",
|
||||
action="store_true",
|
||||
help="For each crash, checkout a fresh local branch from origin/main",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
crashes = resolve_crashes(args)
|
||||
if not crashes:
|
||||
print("No crash IDs were selected.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
crash_ids = [item["crash_id"] for item in crashes]
|
||||
|
||||
if args.select_only:
|
||||
if args.prefetch_dir:
|
||||
prefetch_crash_data(crashes, args.prefetch_dir)
|
||||
print(",".join(crash_ids))
|
||||
return 0
|
||||
|
||||
print(f"Running local dry-run for crashes: {', '.join(crash_ids)}")
|
||||
results = [run_pipeline_for_crash(args, crash_id) for crash_id in crash_ids]
|
||||
|
||||
print("\nRun summary:")
|
||||
for result in results:
|
||||
print(
|
||||
f"- {result['crash_id']}: droid_exit={result['droid_exit_code']} "
|
||||
f"changes={result['has_changes']} branch={result['branch']}"
|
||||
)
|
||||
|
||||
failures = [result for result in results if result["droid_exit_code"] != 0]
|
||||
return 1 if failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
15
script/run-local-minio
Executable file
15
script/run-local-minio
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
if ! which minio > /dev/null; then
|
||||
echo "minio not found - run script/bootstrap to install it and do other setup"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p .blob_store/the-extensions-bucket
|
||||
mkdir -p .blob_store/zed-crash-reports
|
||||
|
||||
export MINIO_ROOT_USER=the-blob-store-access-key
|
||||
export MINIO_ROOT_PASSWORD=the-blob-store-secret-key
|
||||
exec minio server --quiet .blob_store
|
||||
9
script/run-unit-evals
Executable file
9
script/run-unit-evals
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
if [ -n "${UNIT_EVAL_COMMIT:-}" ]; then
|
||||
git fetch origin "$UNIT_EVAL_COMMIT" && git checkout "$UNIT_EVAL_COMMIT"
|
||||
fi
|
||||
|
||||
GPUI_TEST_TIMEOUT=1500 cargo nextest run --workspace --no-fail-fast --features unit-eval --no-capture -E 'test(::eval_)'
|
||||
4
script/seed-db
Executable file
4
script/seed-db
Executable file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
cargo run -p collab migrate
|
||||
242
script/select-sentry-crash-candidates
Executable file
242
script/select-sentry-crash-candidates
Executable file
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Select top Sentry crash candidates ranked by solvability x impact.
|
||||
|
||||
Usage:
|
||||
script/select-sentry-crash-candidates --top 3 --output /tmp/candidates.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import configparser
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
SENTRY_BASE_URL = "https://sentry.io/api/0"
|
||||
DEFAULT_SENTRY_ORG = "zed-dev"
|
||||
DEFAULT_QUERY = "is:unresolved issue.category:error"
|
||||
|
||||
|
||||
class FetchError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def find_auth_token() -> str | None:
|
||||
token = os.environ.get("SENTRY_AUTH_TOKEN")
|
||||
if token:
|
||||
return token
|
||||
|
||||
sentryclirc_path = os.path.expanduser("~/.sentryclirc")
|
||||
if os.path.isfile(sentryclirc_path):
|
||||
config = configparser.ConfigParser()
|
||||
try:
|
||||
config.read(sentryclirc_path)
|
||||
token = config.get("auth", "token", fallback=None)
|
||||
if token:
|
||||
return token
|
||||
except configparser.Error:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def api_get(path: str, token: str):
|
||||
url = f"{SENTRY_BASE_URL}{path}"
|
||||
request = urllib.request.Request(url)
|
||||
request.add_header("Authorization", f"Bearer {token}")
|
||||
request.add_header("Accept", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as error:
|
||||
body = error.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
detail = json.loads(body).get("detail", body)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
detail = body
|
||||
raise FetchError(f"Sentry API returned HTTP {error.code} for {path}: {detail}")
|
||||
except urllib.error.URLError as error:
|
||||
raise FetchError(f"Failed to connect to Sentry API: {error.reason}")
|
||||
|
||||
|
||||
def fetch_issues(token: str, organization: str, limit: int, query: str):
|
||||
encoded_query = urllib.parse.quote(query)
|
||||
path = (
|
||||
f"/organizations/{organization}/issues/"
|
||||
f"?limit={limit}&sort=freq&query={encoded_query}"
|
||||
)
|
||||
return api_get(path, token)
|
||||
|
||||
|
||||
def fetch_latest_event(token: str, issue_id: str):
|
||||
return api_get(f"/issues/{issue_id}/events/latest/", token)
|
||||
|
||||
|
||||
def parse_int(value, fallback=0) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
|
||||
|
||||
def in_app_frame_count(event) -> int:
|
||||
entries = event.get("entries", [])
|
||||
count = 0
|
||||
for entry in entries:
|
||||
if entry.get("type") != "exception":
|
||||
continue
|
||||
exceptions = entry.get("data", {}).get("values", [])
|
||||
for exception in exceptions:
|
||||
frames = (exception.get("stacktrace") or {}).get("frames") or []
|
||||
count += sum(1 for frame in frames if frame.get("inApp") or frame.get("in_app"))
|
||||
return count
|
||||
|
||||
|
||||
def crash_signal_text(issue, event) -> str:
|
||||
title = (issue.get("title") or "").lower()
|
||||
culprit = (issue.get("culprit") or "").lower()
|
||||
message = ""
|
||||
|
||||
entries = event.get("entries", [])
|
||||
for entry in entries:
|
||||
if entry.get("type") != "exception":
|
||||
continue
|
||||
exceptions = entry.get("data", {}).get("values", [])
|
||||
for exception in exceptions:
|
||||
value = exception.get("value")
|
||||
if value:
|
||||
message = value.lower()
|
||||
break
|
||||
if message:
|
||||
break
|
||||
|
||||
return f"{title} {culprit} {message}".strip()
|
||||
|
||||
|
||||
def solvable_factor(issue, event) -> tuple[float, list[str]]:
|
||||
factor = 0.6
|
||||
reasons: list[str] = []
|
||||
|
||||
in_app_frames = in_app_frame_count(event)
|
||||
if in_app_frames >= 6:
|
||||
factor += 0.5
|
||||
reasons.append("strong in-app stack coverage")
|
||||
elif in_app_frames >= 3:
|
||||
factor += 0.3
|
||||
reasons.append("moderate in-app stack coverage")
|
||||
else:
|
||||
factor -= 0.1
|
||||
reasons.append("limited in-app stack coverage")
|
||||
|
||||
signal_text = crash_signal_text(issue, event)
|
||||
if "panic" in signal_text or "assert" in signal_text:
|
||||
factor += 0.2
|
||||
reasons.append("panic/assert style failure")
|
||||
|
||||
if "out of memory" in signal_text or "oom" in signal_text:
|
||||
factor -= 0.35
|
||||
reasons.append("likely resource/system failure")
|
||||
|
||||
if "segmentation fault" in signal_text or "sigsegv" in signal_text:
|
||||
factor -= 0.2
|
||||
reasons.append("low-level crash signal")
|
||||
|
||||
level = (issue.get("level") or "").lower()
|
||||
if level == "error":
|
||||
factor += 0.1
|
||||
|
||||
return max(0.2, min(1.5, factor)), reasons
|
||||
|
||||
|
||||
def candidate_payload(issue, event):
|
||||
issue_id = str(issue.get("id"))
|
||||
short_id = issue.get("shortId") or issue_id
|
||||
issue_count = parse_int(issue.get("count"), 0)
|
||||
user_count = parse_int(issue.get("userCount"), 0)
|
||||
population_score = issue_count + (user_count * 10)
|
||||
solvability, reasons = solvable_factor(issue, event)
|
||||
|
||||
score = int(math.floor(population_score * solvability))
|
||||
issue_url = f"https://sentry.io/organizations/{DEFAULT_SENTRY_ORG}/issues/{issue_id}/"
|
||||
|
||||
return {
|
||||
"issue_id": issue_id,
|
||||
"short_id": short_id,
|
||||
"title": issue.get("title") or "Unknown",
|
||||
"count": issue_count,
|
||||
"user_count": user_count,
|
||||
"population_score": population_score,
|
||||
"solvability_factor": round(solvability, 2),
|
||||
"score": score,
|
||||
"sentry_url": issue_url,
|
||||
"reasons": reasons,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Select top Sentry crash candidates ranked by solvability x impact."
|
||||
)
|
||||
parser.add_argument("--org", default=DEFAULT_SENTRY_ORG, help="Sentry organization slug")
|
||||
parser.add_argument("--query", default=DEFAULT_QUERY, help="Sentry issue query")
|
||||
parser.add_argument("--top", type=int, default=3, help="Number of candidates to select")
|
||||
parser.add_argument(
|
||||
"--sample-size",
|
||||
type=int,
|
||||
default=25,
|
||||
help="Number of unresolved issues to consider before ranking",
|
||||
)
|
||||
parser.add_argument("--output", required=True, help="Output JSON file path")
|
||||
args = parser.parse_args()
|
||||
|
||||
token = find_auth_token()
|
||||
if not token:
|
||||
print(
|
||||
"Error: No Sentry auth token found. Set SENTRY_AUTH_TOKEN or run sentry-cli login.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
try:
|
||||
issues = fetch_issues(token, args.org, args.sample_size, args.query)
|
||||
except FetchError as error:
|
||||
print(f"Error fetching issues: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
candidates = []
|
||||
for issue in issues:
|
||||
issue_id = issue.get("id")
|
||||
if not issue_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
event = fetch_latest_event(token, str(issue_id))
|
||||
except FetchError:
|
||||
continue
|
||||
|
||||
candidates.append(candidate_payload(issue, event))
|
||||
|
||||
candidates.sort(key=lambda candidate: candidate["score"], reverse=True)
|
||||
selected = candidates[: max(1, args.top)]
|
||||
|
||||
output = {
|
||||
"organization": args.org,
|
||||
"query": args.query,
|
||||
"sample_size": args.sample_size,
|
||||
"top": args.top,
|
||||
"selected": selected,
|
||||
}
|
||||
|
||||
with open(args.output, "w", encoding="utf-8") as file:
|
||||
json.dump(output, file, indent=2)
|
||||
|
||||
print(json.dumps(output, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
357
script/sentry-fetch
Executable file
357
script/sentry-fetch
Executable file
@@ -0,0 +1,357 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch a crash report from Sentry and output formatted markdown.
|
||||
|
||||
Usage:
|
||||
script/sentry-fetch <issue-short-id-or-numeric-id>
|
||||
script/sentry-fetch ZED-4VS
|
||||
script/sentry-fetch 7243282041
|
||||
|
||||
Authentication (checked in order):
|
||||
1. SENTRY_AUTH_TOKEN environment variable
|
||||
2. Token from ~/.sentryclirc (written by `sentry-cli login`)
|
||||
|
||||
If neither is found, the script will print setup instructions and exit.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import configparser
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
SENTRY_BASE_URL = "https://sentry.io/api/0"
|
||||
DEFAULT_SENTRY_ORG = "zed-dev"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Fetch a crash report from Sentry and output formatted markdown."
|
||||
)
|
||||
parser.add_argument(
|
||||
"issue",
|
||||
help="Sentry issue short ID (e.g. ZED-4VS) or numeric issue ID",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
token = find_auth_token()
|
||||
if not token:
|
||||
print(
|
||||
"Error: No Sentry auth token found.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"\nSet up authentication using one of these methods:\n"
|
||||
" 1. Run `sentry-cli login` (stores token in ~/.sentryclirc)\n"
|
||||
" 2. Set the SENTRY_AUTH_TOKEN environment variable\n"
|
||||
"\nGet a token at https://sentry.io/settings/auth-tokens/",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
issue_id, short_id, issue = resolve_issue(args.issue, token)
|
||||
event = fetch_latest_event(issue_id, token)
|
||||
except FetchError as err:
|
||||
print(f"Error: {err}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
markdown = format_crash_report(issue, event, short_id)
|
||||
print(markdown)
|
||||
|
||||
|
||||
class FetchError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def find_auth_token():
|
||||
"""Find a Sentry auth token from environment or ~/.sentryclirc.
|
||||
|
||||
Checks in order:
|
||||
1. SENTRY_AUTH_TOKEN environment variable
|
||||
2. auth.token in ~/.sentryclirc (INI format, written by `sentry-cli login`)
|
||||
"""
|
||||
token = os.environ.get("SENTRY_AUTH_TOKEN")
|
||||
if token:
|
||||
return token
|
||||
|
||||
sentryclirc_path = os.path.expanduser("~/.sentryclirc")
|
||||
if os.path.isfile(sentryclirc_path):
|
||||
config = configparser.ConfigParser()
|
||||
try:
|
||||
config.read(sentryclirc_path)
|
||||
token = config.get("auth", "token", fallback=None)
|
||||
if token:
|
||||
return token
|
||||
except configparser.Error:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def api_get(path, token):
|
||||
"""Make an authenticated GET request to the Sentry API."""
|
||||
url = f"{SENTRY_BASE_URL}{path}"
|
||||
req = urllib.request.Request(url)
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
req.add_header("Accept", "application/json")
|
||||
try:
|
||||
with urllib.request.urlopen(req) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as err:
|
||||
body = err.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
detail = json.loads(body).get("detail", body)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
detail = body
|
||||
raise FetchError(f"Sentry API returned HTTP {err.code} for {path}: {detail}")
|
||||
except urllib.error.URLError as err:
|
||||
raise FetchError(f"Failed to connect to Sentry API: {err.reason}")
|
||||
|
||||
|
||||
def resolve_issue(identifier, token):
|
||||
"""Resolve a Sentry issue by short ID or numeric ID.
|
||||
|
||||
Returns (issue_id, short_id, issue_data).
|
||||
"""
|
||||
if identifier.isdigit():
|
||||
issue = api_get(f"/issues/{identifier}/", token)
|
||||
return identifier, issue.get("shortId", identifier), issue
|
||||
|
||||
result = api_get(f"/organizations/{DEFAULT_SENTRY_ORG}/shortids/{identifier}/", token)
|
||||
group_id = str(result["groupId"])
|
||||
issue = api_get(f"/issues/{group_id}/", token)
|
||||
return group_id, identifier, issue
|
||||
|
||||
|
||||
def fetch_latest_event(issue_id, token):
|
||||
"""Fetch the latest event for an issue."""
|
||||
return api_get(f"/issues/{issue_id}/events/latest/", token)
|
||||
|
||||
|
||||
def format_crash_report(issue, event, short_id):
|
||||
"""Format a Sentry issue and event as a markdown crash report."""
|
||||
lines = []
|
||||
|
||||
title = issue.get("title", "Unknown Crash")
|
||||
lines.append(f"# {title}")
|
||||
lines.append("")
|
||||
|
||||
issue_id = issue.get("id", "unknown")
|
||||
project = issue.get("project", {})
|
||||
project_slug = (
|
||||
project.get("slug", "unknown") if isinstance(project, dict) else str(project)
|
||||
)
|
||||
first_seen = issue.get("firstSeen", "unknown")
|
||||
last_seen = issue.get("lastSeen", "unknown")
|
||||
count = issue.get("count", "unknown")
|
||||
sentry_url = f"https://sentry.io/organizations/{DEFAULT_SENTRY_ORG}/issues/{issue_id}/"
|
||||
|
||||
lines.append(f"**Short ID:** {short_id}")
|
||||
lines.append(f"**Issue ID:** {issue_id}")
|
||||
lines.append(f"**Project:** {project_slug}")
|
||||
lines.append(f"**Sentry URL:** {sentry_url}")
|
||||
lines.append(f"**First Seen:** {first_seen}")
|
||||
lines.append(f"**Last Seen:** {last_seen}")
|
||||
lines.append(f"**Event Count:** {count}")
|
||||
lines.append("")
|
||||
|
||||
format_tags(lines, event)
|
||||
format_entries(lines, event)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_tags(lines, event):
|
||||
"""Extract and format tags from the event."""
|
||||
tags = event.get("tags", [])
|
||||
if not tags:
|
||||
return
|
||||
|
||||
lines.append("## Tags")
|
||||
lines.append("")
|
||||
for tag in tags:
|
||||
key = tag.get("key", "") if isinstance(tag, dict) else ""
|
||||
value = tag.get("value", "") if isinstance(tag, dict) else ""
|
||||
if key:
|
||||
lines.append(f"- **{key}:** {value}")
|
||||
lines.append("")
|
||||
|
||||
|
||||
def format_entries(lines, event):
|
||||
"""Format exception and thread entries from the event."""
|
||||
entries = event.get("entries", [])
|
||||
|
||||
for entry in entries:
|
||||
entry_type = entry.get("type", "")
|
||||
|
||||
if entry_type == "exception":
|
||||
format_exceptions(lines, entry)
|
||||
elif entry_type == "threads":
|
||||
format_threads(lines, entry)
|
||||
|
||||
|
||||
def format_exceptions(lines, entry):
|
||||
"""Format exception entries."""
|
||||
exceptions = entry.get("data", {}).get("values", [])
|
||||
if not exceptions:
|
||||
return
|
||||
|
||||
lines.append("## Exceptions")
|
||||
lines.append("")
|
||||
|
||||
for i, exc in enumerate(exceptions):
|
||||
exc_type = exc.get("type", "Unknown")
|
||||
exc_value = exc.get("value", "")
|
||||
mechanism = exc.get("mechanism", {})
|
||||
|
||||
lines.append(f"### Exception {i + 1}")
|
||||
lines.append(f"**Type:** {exc_type}")
|
||||
if exc_value:
|
||||
lines.append(f"**Value:** {exc_value}")
|
||||
if mechanism:
|
||||
mech_type = mechanism.get("type", "unknown")
|
||||
handled = mechanism.get("handled")
|
||||
if handled is not None:
|
||||
lines.append(f"**Mechanism:** {mech_type} (handled: {handled})")
|
||||
else:
|
||||
lines.append(f"**Mechanism:** {mech_type}")
|
||||
lines.append("")
|
||||
|
||||
stacktrace = exc.get("stacktrace")
|
||||
if stacktrace:
|
||||
frames = stacktrace.get("frames", [])
|
||||
lines.append("#### Stacktrace")
|
||||
lines.append("")
|
||||
lines.append("```")
|
||||
lines.append(format_frames(frames))
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
|
||||
|
||||
def format_threads(lines, entry):
|
||||
"""Format thread entries, focusing on crashed and current threads."""
|
||||
threads = entry.get("data", {}).get("values", [])
|
||||
if not threads:
|
||||
return
|
||||
|
||||
crashed_threads = [t for t in threads if t.get("crashed", False)]
|
||||
current_threads = [
|
||||
t for t in threads if t.get("current", False) and not t.get("crashed", False)
|
||||
]
|
||||
other_threads = [
|
||||
t
|
||||
for t in threads
|
||||
if not t.get("crashed", False) and not t.get("current", False)
|
||||
]
|
||||
|
||||
lines.append("## Threads")
|
||||
lines.append("")
|
||||
|
||||
for thread in crashed_threads + current_threads:
|
||||
format_single_thread(lines, thread, show_frames=True)
|
||||
|
||||
if other_threads:
|
||||
lines.append(f"*({len(other_threads)} other threads omitted)*")
|
||||
lines.append("")
|
||||
|
||||
|
||||
def format_single_thread(lines, thread, show_frames=False):
|
||||
"""Format a single thread entry."""
|
||||
thread_id = thread.get("id", "?")
|
||||
thread_name = thread.get("name", "unnamed")
|
||||
crashed = thread.get("crashed", False)
|
||||
current = thread.get("current", False)
|
||||
|
||||
markers = []
|
||||
if crashed:
|
||||
markers.append("CRASHED")
|
||||
if current:
|
||||
markers.append("current")
|
||||
marker_str = f" ({', '.join(markers)})" if markers else ""
|
||||
|
||||
lines.append(f"### Thread {thread_id}: {thread_name}{marker_str}")
|
||||
lines.append("")
|
||||
|
||||
if not show_frames:
|
||||
return
|
||||
|
||||
stacktrace = thread.get("stacktrace")
|
||||
if not stacktrace:
|
||||
return
|
||||
|
||||
frames = stacktrace.get("frames", [])
|
||||
if frames:
|
||||
lines.append("```")
|
||||
lines.append(format_frames(frames))
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
|
||||
|
||||
def format_frames(frames):
|
||||
"""Format stack trace frames for display.
|
||||
|
||||
Sentry provides frames from outermost caller to innermost callee,
|
||||
so we reverse them to show the most recent (crashing) call first,
|
||||
matching the convention used in most crash report displays.
|
||||
"""
|
||||
output_lines = []
|
||||
|
||||
for frame in reversed(frames):
|
||||
func = frame.get("function") or frame.get("symbol") or "unknown"
|
||||
filename = (
|
||||
frame.get("filename")
|
||||
or frame.get("absPath")
|
||||
or frame.get("abs_path")
|
||||
or "unknown file"
|
||||
)
|
||||
line_no = frame.get("lineNo") or frame.get("lineno")
|
||||
in_app = frame.get("inApp", frame.get("in_app", False))
|
||||
|
||||
app_marker = "(In app)" if in_app else "(Not in app)"
|
||||
line_info = f"Line {line_no}" if line_no else "Line null"
|
||||
|
||||
output_lines.append(f" {func} in {filename} [{line_info}] {app_marker}")
|
||||
|
||||
context_lines = build_context_lines(frame, line_no)
|
||||
output_lines.extend(context_lines)
|
||||
|
||||
return "\n".join(output_lines)
|
||||
|
||||
|
||||
def build_context_lines(frame, suspect_line_no):
|
||||
"""Build context code lines for a single frame.
|
||||
|
||||
Handles both Sentry response formats:
|
||||
- preContext/contextLine/postContext (separate fields)
|
||||
- context as an array of [line_no, code] tuples
|
||||
"""
|
||||
output = []
|
||||
|
||||
pre_context = frame.get("preContext") or frame.get("pre_context") or []
|
||||
context_line = frame.get("contextLine") or frame.get("context_line")
|
||||
post_context = frame.get("postContext") or frame.get("post_context") or []
|
||||
|
||||
if context_line is not None or pre_context or post_context:
|
||||
for code_line in pre_context:
|
||||
output.append(f" {code_line}")
|
||||
if context_line is not None:
|
||||
output.append(f" {context_line} <-- SUSPECT LINE")
|
||||
for code_line in post_context:
|
||||
output.append(f" {code_line}")
|
||||
return output
|
||||
|
||||
context = frame.get("context") or []
|
||||
for ctx_entry in context:
|
||||
if isinstance(ctx_entry, list) and len(ctx_entry) >= 2:
|
||||
ctx_line_no = ctx_entry[0]
|
||||
ctx_code = ctx_entry[1]
|
||||
suspect = " <-- SUSPECT LINE" if ctx_line_no == suspect_line_no else ""
|
||||
output.append(f" {ctx_code}{suspect}")
|
||||
|
||||
return output
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
39
script/setup-dev-driver.ps1
Normal file
39
script/setup-dev-driver.ps1
Normal file
@@ -0,0 +1,39 @@
|
||||
# Configures a drive for testing in CI.
|
||||
|
||||
# Currently, total CI requires almost 45GB of space, here we are creating a 100GB drive.
|
||||
$Volume = New-VHD -Path C:/zed_dev_drive.vhdx -SizeBytes 100GB |
|
||||
Mount-VHD -Passthru |
|
||||
Initialize-Disk -Passthru |
|
||||
New-Partition -AssignDriveLetter -UseMaximumSize |
|
||||
Format-Volume -DevDrive -Confirm:$false -Force
|
||||
|
||||
$Drive = "$($Volume.DriveLetter):"
|
||||
|
||||
# Designate the Dev Drive as trusted
|
||||
# See https://learn.microsoft.com/en-us/windows/dev-drive/#how-do-i-designate-a-dev-drive-as-trusted
|
||||
fsutil devdrv trust $Drive
|
||||
|
||||
# There is no virus on the Dev Drive!
|
||||
# Windows Defender is the wolf in antivirus wool, slowing your PC like a digital fool!
|
||||
# See https://learn.microsoft.com/en-us/windows/dev-drive/#how-do-i-configure-additional-filters-on-dev-drive
|
||||
fsutil devdrv enable /disallowAv
|
||||
|
||||
# Remount so the changes take effect
|
||||
Dismount-VHD -Path C:/zed_dev_drive.vhdx
|
||||
Mount-VHD -Path C:/zed_dev_drive.vhdx
|
||||
|
||||
# Show some debug information
|
||||
Write-Output $Volume
|
||||
Write-Output "Using Dev Drive at $Drive"
|
||||
|
||||
# Move Cargo to the dev drive
|
||||
New-Item -Path "$($Drive)/.cargo/bin" -ItemType Directory -Force
|
||||
Copy-Item -Path "C:/Users/runneradmin/.cargo/*" -Destination "$($Drive)/.cargo/" -Recurse -Force
|
||||
|
||||
Write-Output `
|
||||
"DEV_DRIVE=$($Drive)" `
|
||||
"RUSTUP_HOME=$($Drive)/.rustup" `
|
||||
"CARGO_HOME=$($Drive)/.cargo" `
|
||||
"ZED_WORKSPACE=$($Drive)/zed" `
|
||||
"PATH=$($Drive)/.cargo/bin;$env:PATH" `
|
||||
>> $env:GITHUB_ENV
|
||||
128
script/setup-sccache
Executable file
128
script/setup-sccache
Executable file
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCCACHE_VERSION="v0.10.0"
|
||||
# Use absolute path to avoid issues with working directory changes between steps
|
||||
SCCACHE_DIR="$(pwd)/target/sccache"
|
||||
|
||||
install_sccache() {
|
||||
mkdir -p "$SCCACHE_DIR"
|
||||
|
||||
if [[ -x "${SCCACHE_DIR}/sccache" ]] && "${SCCACHE_DIR}/sccache" --version &>/dev/null; then
|
||||
echo "sccache already cached: $("${SCCACHE_DIR}/sccache" --version)"
|
||||
else
|
||||
echo "Installing sccache ${SCCACHE_VERSION} from GitHub releases..."
|
||||
|
||||
local os arch archive basename
|
||||
os="$(uname -s)"
|
||||
arch="$(uname -m)"
|
||||
|
||||
case "${os}-${arch}" in
|
||||
Darwin-arm64)
|
||||
archive="sccache-${SCCACHE_VERSION}-aarch64-apple-darwin.tar.gz"
|
||||
;;
|
||||
Darwin-x86_64)
|
||||
archive="sccache-${SCCACHE_VERSION}-x86_64-apple-darwin.tar.gz"
|
||||
;;
|
||||
Linux-x86_64)
|
||||
archive="sccache-${SCCACHE_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
;;
|
||||
Linux-aarch64)
|
||||
archive="sccache-${SCCACHE_VERSION}-aarch64-unknown-linux-musl.tar.gz"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported platform: ${os}-${arch}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
basename="${archive%.tar.gz}"
|
||||
curl -fsSL "https://github.com/mozilla/sccache/releases/download/${SCCACHE_VERSION}/${archive}" | tar xz
|
||||
mv "${basename}/sccache" "${SCCACHE_DIR}/"
|
||||
rm -rf "${basename}"
|
||||
echo "Installed sccache: $("${SCCACHE_DIR}/sccache" --version)"
|
||||
fi
|
||||
|
||||
# Verify the binary works before adding to path
|
||||
if ! "${SCCACHE_DIR}/sccache" --version &>/dev/null; then
|
||||
echo "ERROR: sccache binary at ${SCCACHE_DIR}/sccache is not executable or corrupted"
|
||||
rm -f "${SCCACHE_DIR}/sccache"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -n "${GITHUB_PATH:-}" ]]; then
|
||||
echo "${SCCACHE_DIR}" >> "$GITHUB_PATH"
|
||||
fi
|
||||
export PATH="${SCCACHE_DIR}:${PATH}"
|
||||
}
|
||||
|
||||
configure_sccache() {
|
||||
if [[ -z "${R2_ACCOUNT_ID:-}" ]]; then
|
||||
echo "R2_ACCOUNT_ID not set, skipping sccache configuration"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Configuring sccache with Cloudflare R2..."
|
||||
|
||||
local bucket="${SCCACHE_BUCKET:-sccache-zed}"
|
||||
local key_prefix="${SCCACHE_KEY_PREFIX:-sccache/}"
|
||||
local base_dir="${GITHUB_WORKSPACE:-$(pwd)}"
|
||||
|
||||
# Use the absolute path to sccache binary for RUSTC_WRAPPER to avoid
|
||||
# any PATH race conditions between GITHUB_PATH and GITHUB_ENV
|
||||
local sccache_bin="${SCCACHE_DIR}/sccache"
|
||||
|
||||
# Set in current process
|
||||
export SCCACHE_ENDPOINT="https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com"
|
||||
export SCCACHE_BUCKET="${bucket}"
|
||||
export SCCACHE_REGION="auto"
|
||||
export SCCACHE_S3_KEY_PREFIX="${key_prefix}"
|
||||
export SCCACHE_BASEDIR="${base_dir}"
|
||||
export AWS_ACCESS_KEY_ID="${R2_ACCESS_KEY_ID}"
|
||||
export AWS_SECRET_ACCESS_KEY="${R2_SECRET_ACCESS_KEY}"
|
||||
export RUSTC_WRAPPER="${sccache_bin}"
|
||||
|
||||
# Also write to GITHUB_ENV for subsequent steps
|
||||
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||||
{
|
||||
echo "SCCACHE_ENDPOINT=${SCCACHE_ENDPOINT}"
|
||||
echo "SCCACHE_BUCKET=${SCCACHE_BUCKET}"
|
||||
echo "SCCACHE_REGION=${SCCACHE_REGION}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX=${SCCACHE_S3_KEY_PREFIX}"
|
||||
echo "SCCACHE_BASEDIR=${SCCACHE_BASEDIR}"
|
||||
echo "AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}"
|
||||
echo "AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}"
|
||||
echo "RUSTC_WRAPPER=${RUSTC_WRAPPER}"
|
||||
} >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
echo "✓ sccache configured with Cloudflare R2 (bucket: ${bucket})"
|
||||
}
|
||||
|
||||
show_config() {
|
||||
echo "=== sccache configuration ==="
|
||||
echo "sccache version: $(sccache --version)"
|
||||
echo "RUSTC_WRAPPER: ${RUSTC_WRAPPER:-<not set>}"
|
||||
echo "SCCACHE_BUCKET: ${SCCACHE_BUCKET:-<not set>}"
|
||||
echo "SCCACHE_ENDPOINT: ${SCCACHE_ENDPOINT:-<not set>}"
|
||||
echo "SCCACHE_REGION: ${SCCACHE_REGION:-<not set>}"
|
||||
echo "SCCACHE_S3_KEY_PREFIX: ${SCCACHE_S3_KEY_PREFIX:-<not set>}"
|
||||
echo "SCCACHE_BASEDIR: ${SCCACHE_BASEDIR:-<not set>}"
|
||||
if [[ -n "${AWS_ACCESS_KEY_ID:-}" ]]; then
|
||||
echo "AWS_ACCESS_KEY_ID: <set, length=${#AWS_ACCESS_KEY_ID}>"
|
||||
else
|
||||
echo "AWS_ACCESS_KEY_ID: <not set>"
|
||||
fi
|
||||
if [[ -n "${AWS_SECRET_ACCESS_KEY:-}" ]]; then
|
||||
echo "AWS_SECRET_ACCESS_KEY: <set>"
|
||||
else
|
||||
echo "AWS_SECRET_ACCESS_KEY: <not set>"
|
||||
fi
|
||||
echo "=== sccache stats ==="
|
||||
sccache --show-stats || true
|
||||
}
|
||||
|
||||
install_sccache
|
||||
configure_sccache
|
||||
show_config
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user