Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 23 additions & 17 deletions .github/workflows/reviewstack.dev-deploy.yml
Original file line number Diff line number Diff line change
@@ -1,26 +1,33 @@
name: Publish https://reviewstack.dev
name: Deploy ReviewStack to Cloudflare Pages

on:
workflow_dispatch:
schedule:
- cron: '0 0 * * 1-5'
push:
branches:
- main
paths:
- 'eden/contrib/reviewstack/**'
- 'eden/contrib/reviewstack.dev/**'
- 'eden/contrib/shared/**'
- '.github/workflows/reviewstack.dev-deploy.yml'

jobs:
deploy:
runs-on: ubuntu-22.04
# Our build container already has Node, Yarn, and Python installed.
container:
image: ${{ format('ghcr.io/{0}/build_ubuntu_22_04:latest', github.repository) }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
steps:
- name: Checkout Code
uses: actions/checkout@v6
- name: Grant Access
run: git config --global --add safe.directory "$PWD"
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
cache-dependency-path: eden/contrib/yarn.lock
- name: Install dependencies
working-directory: ./eden/contrib/
run: yarn install --prefer-offline
run: yarn install

# Build codegen and then do some sanity checks so we don't push the site
# when the tests are broken.
Expand All @@ -37,11 +44,10 @@ jobs:
working-directory: ./eden/contrib/reviewstack.dev
run: yarn release

# Push to the release branch.
- name: Deploy
uses: peaceiris/actions-gh-pages@v4
if: ${{ github.ref == 'refs/heads/main' }}
# Deploy to Cloudflare Pages
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_branch: reviewstack.dev-prod
publish_dir: ./eden/contrib/reviewstack.dev/build
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy ./eden/contrib/reviewstack.dev/build --project-name=reviewstack
111 changes: 111 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Sapling SCM is a cross-platform, highly scalable, Git-compatible source control system. It consists of three main components:

- **Sapling CLI** (`eden/scm/`): The `sl` command-line tool, based on Mercurial. Written in Python and Rust.
- **Interactive Smartlog (ISL)** (`addons/`): Web-based and VS Code UI for repository visualization. React/TypeScript.
- **Mononoke** (`eden/mononoke/`): Server-side distributed source control server (Rust). Not yet publicly supported.
- **EdenFS** (`eden/fs/`): Virtual filesystem for large checkouts (C++). Not yet publicly supported.

## Build Commands

### Sapling CLI (primary development)

From `eden/scm/`:

```bash
make oss # Build OSS version for local usage (creates ./sl binary)
make install-oss # Install CLI to $PREFIX/bin
make local # Build for inplace usage
make clean # Remove build artifacts
```

### Running Tests

```bash
# All tests (from eden/scm/)
make tests

# Single test
make test-foo # Runs test-foo.t

# Using getdeps (full CI-style testing)
./build/fbcode_builder/getdeps.py test --allow-system-packages --src-dir=. sapling

# Single test with getdeps
./build/fbcode_builder/getdeps.py test --allow-system-packages --src-dir=. sapling --retry 0 --filter test-foo.t
```

### ISL (Interactive Smartlog)

From `addons/`:

```bash
yarn install # Install dependencies
yarn dev # Start dev server for ISL
```

From `addons/isl/`:

```bash
yarn start # Start Vite dev server
yarn build # Production build
yarn test # Run Jest tests
yarn eslint # Lint TypeScript/React code
```

### Website

From `website/`:

```bash
yarn start # Start Docusaurus dev server
yarn build # Build static site
yarn lint # Run ESLint and Stylelint
yarn format:diff # Check formatting with Prettier
yarn typecheck # TypeScript type checking
```

## Architecture

### Sapling CLI (`eden/scm/`)

- `sapling/` - Python CLI modules and commands
- `lib/` - Rust libraries (117+ modules): dag, indexedlog, gitcompat, checkout, clone, etc.
- `exec/` - Rust executables: hgmain, scm_daemon
- `tests/` - `.t` test files (1000+)

### ISL (`addons/`)

Yarn workspace with:
- `isl/` - React/Vite web UI
- `isl-server/` - Node.js backend
- `vscode/` - VS Code extension
- `shared/` - Shared utilities
- `components/` - Reusable UI components

## Code Style

- 2 spaces indentation
- 80 character line length
- Rust: 2024 edition, rustfmt configured in `.rustfmt.toml`
- TypeScript/JavaScript: ESLint + Prettier
- Python: Flake8

## Dependencies

- Python 3.8+
- Rust (stable)
- CMake 3.8+
- OpenSSL
- Node.js + Yarn 1.22 (for ISL/addons)

## Git Workflow

- Branch from `main`
- Developed internally at Meta, exported to GitHub
- CLA required for contributions (https://code.facebook.com/cla)
1 change: 1 addition & 0 deletions CLAUDE.md
1 change: 1 addition & 0 deletions addons/components/KeyboardShortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export enum KeyCode {
R = 82,
S = 83,
T = 84,
Comma = 188,
Period = 190,
QuestionMark = 191,
SingleQuote = 222,
Expand Down
2 changes: 1 addition & 1 deletion addons/isl-server/src/CodeReviewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export type DiffSummaries = Map<DiffId, DiffSummary>;
export interface CodeReviewProvider {
triggerDiffSummariesFetch(diffs: Array<DiffId>): unknown;

onChangeDiffSummaries(callback: (result: Result<DiffSummaries>) => unknown): Disposable;
onChangeDiffSummaries(callback: (result: Result<DiffSummaries>, currentUser?: string) => unknown): Disposable;

/** Run a command not handled within sapling, such as a separate submit handler */
runExternalCommand?(
Expand Down
4 changes: 2 additions & 2 deletions addons/isl-server/src/ServerToClientAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,8 @@ export default class ServerToClientAPI {

if (repo.codeReviewProvider != null) {
this.repoDisposables.push(
repo.codeReviewProvider.onChangeDiffSummaries(value => {
this.postMessage({type: 'fetchedDiffSummaries', summaries: value});
repo.codeReviewProvider.onChangeDiffSummaries((value, currentUser) => {
this.postMessage({type: 'fetchedDiffSummaries', summaries: value, currentUser});
}),
);
}
Expand Down
1 change: 1 addition & 0 deletions addons/isl-server/src/analytics/eventNames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ export type TrackEventName =
| 'PrSubmitOperation'
| 'PullOperation'
| 'PullRevOperation'
| 'PullStackOperation'
| 'PurgeOperation'
| 'RebaseWarningTimeout'
| 'RebaseKeepOperation'
Expand Down
18 changes: 16 additions & 2 deletions addons/isl-server/src/github/generated/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29508,15 +29508,15 @@ export type YourPullRequestsQueryVariables = Exact<{
}>;


export type YourPullRequestsQueryData = { __typename?: 'Query', search: { __typename?: 'SearchResultItemConnection', nodes?: Array<{ __typename?: 'App' } | { __typename?: 'Discussion' } | { __typename?: 'Issue' } | { __typename?: 'MarketplaceListing' } | { __typename?: 'Organization' } | { __typename: 'PullRequest', number: number, title: string, body: string, state: PullRequestState, isDraft: boolean, url: string, reviewDecision?: PullRequestReviewDecision | null, comments: { __typename?: 'IssueCommentConnection', totalCount: number }, mergeQueueEntry?: { __typename?: 'MergeQueueEntry', estimatedTimeToMerge?: number | null } | null, baseRef?: { __typename?: 'Ref', name: string, target?: { __typename?: 'Blob', oid: string } | { __typename?: 'Commit', oid: string } | { __typename?: 'Tag', oid: string } | { __typename?: 'Tree', oid: string } | null } | null, headRef?: { __typename?: 'Ref', name: string, target?: { __typename?: 'Blob', oid: string } | { __typename?: 'Commit', oid: string } | { __typename?: 'Tag', oid: string } | { __typename?: 'Tree', oid: string } | null } | null, commits: { __typename?: 'PullRequestCommitConnection', nodes?: Array<{ __typename?: 'PullRequestCommit', commit: { __typename?: 'Commit', oid: string, statusCheckRollup?: { __typename?: 'StatusCheckRollup', state: StatusState } | null } } | null> | null } } | { __typename?: 'Repository' } | { __typename?: 'User' } | null> | null } };
export type YourPullRequestsQueryData = { __typename?: 'Query', viewer: { __typename?: 'User', login: string }, search: { __typename?: 'SearchResultItemConnection', nodes?: Array<{ __typename?: 'App' } | { __typename?: 'Discussion' } | { __typename?: 'Issue' } | { __typename?: 'MarketplaceListing' } | { __typename?: 'Organization' } | { __typename: 'PullRequest', number: number, title: string, body: string, state: PullRequestState, isDraft: boolean, url: string, reviewDecision?: PullRequestReviewDecision | null, author?: { __typename?: 'Bot', login: string, avatarUrl: string } | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: string } | { __typename?: 'Mannequin', login: string, avatarUrl: string } | { __typename?: 'Organization', login: string, avatarUrl: string } | { __typename?: 'User', login: string, avatarUrl: string } | null, comments: { __typename?: 'IssueCommentConnection', totalCount: number }, mergeQueueEntry?: { __typename?: 'MergeQueueEntry', estimatedTimeToMerge?: number | null } | null, baseRef?: { __typename?: 'Ref', name: string, target?: { __typename?: 'Blob', oid: string } | { __typename?: 'Commit', oid: string } | { __typename?: 'Tag', oid: string } | { __typename?: 'Tree', oid: string } | null } | null, headRef?: { __typename?: 'Ref', name: string, target?: { __typename?: 'Blob', oid: string } | { __typename?: 'Commit', oid: string } | { __typename?: 'Tag', oid: string } | { __typename?: 'Tree', oid: string } | null } | null, commits: { __typename?: 'PullRequestCommitConnection', nodes?: Array<{ __typename?: 'PullRequestCommit', commit: { __typename?: 'Commit', oid: string, statusCheckRollup?: { __typename?: 'StatusCheckRollup', state: StatusState } | null } } | null> | null } } | { __typename?: 'Repository' } | { __typename?: 'User' } | null> | null } };

export type YourPullRequestsWithoutMergeQueueQueryVariables = Exact<{
searchQuery: Scalars['String'];
numToFetch: Scalars['Int'];
}>;


export type YourPullRequestsWithoutMergeQueueQueryData = { __typename?: 'Query', search: { __typename?: 'SearchResultItemConnection', nodes?: Array<{ __typename?: 'App' } | { __typename?: 'Discussion' } | { __typename?: 'Issue' } | { __typename?: 'MarketplaceListing' } | { __typename?: 'Organization' } | { __typename: 'PullRequest', number: number, title: string, body: string, state: PullRequestState, isDraft: boolean, url: string, reviewDecision?: PullRequestReviewDecision | null, comments: { __typename?: 'IssueCommentConnection', totalCount: number }, baseRef?: { __typename?: 'Ref', name: string, target?: { __typename?: 'Blob', oid: string } | { __typename?: 'Commit', oid: string } | { __typename?: 'Tag', oid: string } | { __typename?: 'Tree', oid: string } | null } | null, headRef?: { __typename?: 'Ref', name: string, target?: { __typename?: 'Blob', oid: string } | { __typename?: 'Commit', oid: string } | { __typename?: 'Tag', oid: string } | { __typename?: 'Tree', oid: string } | null } | null, commits: { __typename?: 'PullRequestCommitConnection', nodes?: Array<{ __typename?: 'PullRequestCommit', commit: { __typename?: 'Commit', oid: string, statusCheckRollup?: { __typename?: 'StatusCheckRollup', state: StatusState } | null } } | null> | null } } | { __typename?: 'Repository' } | { __typename?: 'User' } | null> | null } };
export type YourPullRequestsWithoutMergeQueueQueryData = { __typename?: 'Query', viewer: { __typename?: 'User', login: string }, search: { __typename?: 'SearchResultItemConnection', nodes?: Array<{ __typename?: 'App' } | { __typename?: 'Discussion' } | { __typename?: 'Issue' } | { __typename?: 'MarketplaceListing' } | { __typename?: 'Organization' } | { __typename: 'PullRequest', number: number, title: string, body: string, state: PullRequestState, isDraft: boolean, url: string, reviewDecision?: PullRequestReviewDecision | null, author?: { __typename?: 'Bot', login: string, avatarUrl: string } | { __typename?: 'EnterpriseUserAccount', login: string, avatarUrl: string } | { __typename?: 'Mannequin', login: string, avatarUrl: string } | { __typename?: 'Organization', login: string, avatarUrl: string } | { __typename?: 'User', login: string, avatarUrl: string } | null, comments: { __typename?: 'IssueCommentConnection', totalCount: number }, baseRef?: { __typename?: 'Ref', name: string, target?: { __typename?: 'Blob', oid: string } | { __typename?: 'Commit', oid: string } | { __typename?: 'Tag', oid: string } | { __typename?: 'Tree', oid: string } | null } | null, headRef?: { __typename?: 'Ref', name: string, target?: { __typename?: 'Blob', oid: string } | { __typename?: 'Commit', oid: string } | { __typename?: 'Tag', oid: string } | { __typename?: 'Tree', oid: string } | null } | null, commits: { __typename?: 'PullRequestCommitConnection', nodes?: Array<{ __typename?: 'PullRequestCommit', commit: { __typename?: 'Commit', oid: string, statusCheckRollup?: { __typename?: 'StatusCheckRollup', state: StatusState } | null } } | null> | null } } | { __typename?: 'Repository' } | { __typename?: 'User' } | null> | null } };

export const CommentParts = `
fragment CommentParts on Comment {
Expand Down Expand Up @@ -29578,6 +29578,9 @@ export const PullRequestCommentsQuery = `
${ReactionParts}`;
export const YourPullRequestsQuery = `
query YourPullRequestsQuery($searchQuery: String!, $numToFetch: Int!) {
viewer {
login
}
search(query: $searchQuery, type: ISSUE, first: $numToFetch) {
nodes {
... on PullRequest {
Expand All @@ -29587,6 +29590,10 @@ export const YourPullRequestsQuery = `
body
state
isDraft
author {
login
avatarUrl
}
url
reviewDecision
comments {
Expand Down Expand Up @@ -29624,6 +29631,9 @@ export const YourPullRequestsQuery = `
`;
export const YourPullRequestsWithoutMergeQueueQuery = `
query YourPullRequestsWithoutMergeQueueQuery($searchQuery: String!, $numToFetch: Int!) {
viewer {
login
}
search(query: $searchQuery, type: ISSUE, first: $numToFetch) {
nodes {
... on PullRequest {
Expand All @@ -29633,6 +29643,10 @@ export const YourPullRequestsWithoutMergeQueueQuery = `
body
state
isDraft
author {
login
avatarUrl
}
url
reviewDecision
comments {
Expand Down
41 changes: 32 additions & 9 deletions addons/isl-server/src/github/githubCodeReviewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
YourPullRequestsQuery,
YourPullRequestsWithoutMergeQueueQuery,
} from './generated/graphql';
import {parseStackInfo, type StackEntry} from './parseStackInfo';
import queryGraphQL from './queryGraphQL';

export type GitHubDiffSummary = {
Expand All @@ -60,23 +61,35 @@ export type GitHubDiffSummary = {
head: Hash;
/** Name of the branch on GitHub, which should match the local bookmark */
branchName?: string;
/** Stack info parsed from PR body Sapling footer. Top-to-bottom order (first = top of stack). */
stackInfo?: StackEntry[];
/** Author login (GitHub username) */
author?: string;
/** Author avatar URL */
authorAvatarUrl?: string;
};

const DEFAULT_GH_FETCH_TIMEOUT = 60_000; // 1 minute

export type DiffSummariesData = {
summaries: Map<DiffId, GitHubDiffSummary>;
currentUser?: string;
};

type GitHubCodeReviewSystem = CodeReviewSystem & {type: 'github'};
export class GitHubCodeReviewProvider implements CodeReviewProvider {
constructor(
private codeReviewSystem: GitHubCodeReviewSystem,
private logger: Logger,
) {}
private diffSummaries = new TypedEventEmitter<'data', Map<DiffId, GitHubDiffSummary>>();
private diffSummaries = new TypedEventEmitter<'data', DiffSummariesData>();
private hasMergeQueueSupport: Promise<boolean> | null = null;

onChangeDiffSummaries(
callback: (result: Result<Map<DiffId, GitHubDiffSummary>>) => unknown,
callback: (result: Result<Map<DiffId, GitHubDiffSummary>>, currentUser?: string) => unknown,
): Disposable {
const handleData = (data: Map<DiffId, GitHubDiffSummary>) => callback({value: data});
const handleData = (data: DiffSummariesData) =>
callback({value: data.summaries}, data.currentUser);
const handleError = (error: Error) => callback({error});
this.diffSummaries.on('data', handleData);
this.diffSummaries.on('error', handleError);
Expand Down Expand Up @@ -111,11 +124,14 @@ export class GitHubCodeReviewProvider implements CodeReviewProvider {
private fetchYourPullRequestsGraphQL(
includeMergeQueue: boolean,
): Promise<YourPullRequestsQueryData | undefined> {
// Calculate date 30 days ago for the updated filter
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const dateFilter = thirtyDaysAgo.toISOString().split('T')[0];

const variables = {
// TODO: somehow base this query on the list of DiffIds
// This is not very easy with github's graphql API, which doesn't allow more than 5 "OR"s in a search query.
// But if we used one-query-per-diff we would reach rate limiting too quickly.
searchQuery: `repo:${this.codeReviewSystem.owner}/${this.codeReviewSystem.repo} is:pr author:@me`,
// Fetch all open PRs in the repo updated in the last 30 days
searchQuery: `repo:${this.codeReviewSystem.owner}/${this.codeReviewSystem.repo} is:pr is:open updated:>=${dateFilter}`,
numToFetch: 50,
};
if (includeMergeQueue) {
Expand All @@ -138,9 +154,10 @@ export class GitHubCodeReviewProvider implements CodeReviewProvider {
this.logger.info('fetching github PR summaries');
const allSummaries = await this.fetchYourPullRequestsGraphQL(hasMergeQueueSupport);
if (allSummaries?.search.nodes == null) {
this.diffSummaries.emit('data', new Map());
this.diffSummaries.emit('data', {summaries: new Map()});
return;
}
const currentUser = allSummaries.viewer?.login;

const map = new Map<DiffId, GitHubDiffSummary>();
for (const summary of allSummaries.search.nodes) {
Expand All @@ -151,6 +168,9 @@ export class GitHubCodeReviewProvider implements CodeReviewProvider {
this.logger.warn(`PR #${id} is missing base or head ref, skipping.`);
continue;
}
// Parse stack info from the PR body (Sapling footer format)
const stackInfo = parseStackInfo(summary.body) ?? undefined;

map.set(id, {
type: 'github',
title: summary.title,
Expand All @@ -174,11 +194,14 @@ export class GitHubCodeReviewProvider implements CodeReviewProvider {
base: summary.baseRef.target.oid,
head: summary.headRef.target.oid,
branchName: summary.headRef.name,
stackInfo,
author: summary.author?.login ?? undefined,
authorAvatarUrl: summary.author?.avatarUrl ?? undefined,
});
}
}
this.logger.info(`fetched ${map.size} github PR summaries`);
this.diffSummaries.emit('data', map);
this.diffSummaries.emit('data', {summaries: map, currentUser});
} catch (error) {
this.logger.info('error fetching github PR summaries: ', error);
this.diffSummaries.emit('error', error as Error);
Expand Down
Loading