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:
Mohamad Khani
2026-07-14 01:52:12 +03:30
commit b9819977a5
3984 changed files with 1487015 additions and 0 deletions

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"

View 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

View 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
View 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
View 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
View 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"

View 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
View 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
View 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
View File

@@ -0,0 +1 @@
node_modules/

102
script/danger/dangerfile.ts Normal file
View 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"),
);
}
}

View 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
View 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
View 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
View 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

View 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

View 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
View 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"

View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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'"

View 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
View 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
View 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
View 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
View 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/&quot;/"/g' "$OUTPUT_FILE"
sed -i.bak 's/&#x27;/'\''/g' "$OUTPUT_FILE" # The ` '\'' ` thing ends the string, appends a single quote, and re-opens the string
sed -i.bak 's/&#x3D;/=/g' "$OUTPUT_FILE"
sed -i.bak 's/&#x60;/`/g' "$OUTPUT_FILE"
sed -i.bak 's/&lt;/</g' "$OUTPUT_FILE"
sed -i.bak 's/&gt;/>/g' "$OUTPUT_FILE"
rm -rf "${OUTPUT_FILE}.bak"
echo "generate-licenses completed. See $OUTPUT_FILE"

26
script/generate-licenses-csv Executable file
View 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"

View 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 = @{
'&quot;' = '"'
'&#x27;' = "'"
'&#x3D;' = '='
'&#x60;' = '`'
'&lt;' = '<'
'&gt;' = '>'
}
$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
View 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
View 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"

View 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
View 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
View 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
View 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

View 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

View 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 590 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,
}))

View 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)

View 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}")

View 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)

View 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
View 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()

View 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
View 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
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
cargo run -p theme_importer -- "$@"

77
script/install-cmake Executable file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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
}

View 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}}

View 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.
--------------------------------------------------------------------------------

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
psql postgres -c "DROP DATABASE zed WITH (FORCE);"
psql postgres -c "DROP DATABASE zed_llm WITH (FORCE);"
script/bootstrap

View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -e
cargo run -p collab migrate

View 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
View 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()

View 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
View 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