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
37 changes: 37 additions & 0 deletions eden/contrib/reviewstack.dev/functions/_oauth/callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
interface GitHubTokenResponse {
access_token?: string;
error?: string;
error_description?: string;
}

export const onRequestGet: PagesFunction<{ CLIENT_ID: string; CLIENT_SECRET: string }> = async (context) => {
const url = new URL(context.request.url);
const code = url.searchParams.get('code');

if (!code) {
return new Response('Missing code parameter', { status: 400 });
}

const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
client_id: context.env.CLIENT_ID,
client_secret: context.env.CLIENT_SECRET,
code,
}),
});

const data: GitHubTokenResponse = await tokenResponse.json();

if (data.error || !data.access_token) {
const errorMsg = encodeURIComponent(data.error_description || data.error || 'Unknown error');
return Response.redirect(`${url.origin}/?error=${errorMsg}`, 302);
}

// Redirect back to app with token in hash (not exposed to server logs)
return Response.redirect(`${url.origin}/#token=${data.access_token}`, 302);
};
9 changes: 9 additions & 0 deletions eden/contrib/reviewstack.dev/functions/_oauth/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const onRequestGet: PagesFunction<{ CLIENT_ID: string }> = async (context) => {
const redirectUri = new URL('/_oauth/callback', context.request.url).toString();
const githubAuthUrl = new URL('https://github.com/login/oauth/authorize');
githubAuthUrl.searchParams.set('client_id', context.env.CLIENT_ID);
githubAuthUrl.searchParams.set('redirect_uri', redirectUri);
githubAuthUrl.searchParams.set('scope', 'user repo');

return Response.redirect(githubAuthUrl.toString(), 302);
};
4 changes: 3 additions & 1 deletion eden/contrib/reviewstack.dev/src/LazyLoginDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export default function LazyLoginDialog({
}) {
const {hostname} = window.location;
const LoginComponent =
hostname === 'reviewstack.netlify.app' || hostname === 'reviewstack.dev'
hostname === 'reviewstack.netlify.app' ||
hostname === 'reviewstack.dev' ||
hostname === 'reviews.qlax.dev'
? NetlifyLoginDialog
: DefaultLoginDialog;

Expand Down
61 changes: 25 additions & 36 deletions eden/contrib/reviewstack.dev/src/NetlifyLoginDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,10 @@ import type {CustomLoginDialogProps} from 'reviewstack/src/LoginDialog';
import Footer from './Footer';
import InlineCode from './InlineCode';
import {Box, Button, Heading, Text, TextInput} from '@primer/react';
import Authenticator from 'netlify-auth-providers';
import React, {useCallback, useState} from 'react';
import React, {useCallback, useEffect, useState} from 'react';
import AppHeader from 'reviewstack/src/AppHeader';
import Link from 'reviewstack/src/Link';

/**
* See https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps
*/
const GITHUB_OAUTH_SCOPE = ['user', 'repo'].join(' ');

export default function NetlifyLoginDialog(props: CustomLoginDialogProps): React.ReactElement {
return (
<Box display="flex" flexDirection="column" height="100vh">
Expand Down Expand Up @@ -58,17 +52,32 @@ function EndUserInstructions(props: CustomLoginDialogProps): React.ReactElement
const {setTokenAndHostname} = props;
const [isButtonDisabled, setButtonDisabled] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const onClick = useCallback(async () => {
setButtonDisabled(true);
try {
const token = await fetchGitHubToken();

// Check for OAuth token in URL hash on mount (from OAuth callback)
useEffect(() => {
const hash = window.location.hash;
const tokenMatch = hash.match(/token=([^&]+)/);
if (tokenMatch) {
const token = tokenMatch[1];
// Clear the hash from URL
window.history.replaceState(null, '', window.location.pathname + window.location.search);
setTokenAndHostname(token, 'github.com');
} catch (e) {
const message = e instanceof Error ? e.message : 'error fetching OAuth token';
setErrorMessage(message);
}
setButtonDisabled(false);
}, [setButtonDisabled, setErrorMessage, setTokenAndHostname]);
// Check for error in URL params (from failed OAuth)
const params = new URLSearchParams(window.location.search);
const error = params.get('error');
if (error) {
setErrorMessage(error);
// Clear error from URL
window.history.replaceState(null, '', window.location.pathname);
}
}, [setTokenAndHostname]);

const onClick = useCallback(() => {
setButtonDisabled(true);
// Redirect to OAuth login endpoint
window.location.href = '/_oauth/login';
}, []);

return (
<Box>
Expand Down Expand Up @@ -222,23 +231,3 @@ function H3({children}: {children: React.ReactNode}): React.ReactElement {
);
}

function fetchGitHubToken(): Promise<string> {
return new Promise((resolve, reject) => {
const authenticator = new Authenticator({});
authenticator.authenticate(
{provider: 'github', scope: GITHUB_OAUTH_SCOPE},
(error: Error | null, data: {token: string} | null) => {
if (error) {
reject(error);
} else {
const token = data?.token;
if (typeof token === 'string') {
resolve(token);
} else {
reject(new Error('token missing in OAuth response'));
}
}
},
);
});
}
4 changes: 3 additions & 1 deletion eden/contrib/reviewstack/src/PullRequest.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,11 @@ function PullRequestDetails() {

const stack = pullRequestStack.contents;
const { bodyHTML } = pullRequest;
let pullRequestBodyHTML;
let pullRequestBodyHTML: string;
switch (stack.type) {
case 'no-stack':
case 'graphite':
// Graphite stack info is in a comment, not the PR body, so no stripping needed
pullRequestBodyHTML = bodyHTML;
break;
case 'sapling':
Expand Down
31 changes: 27 additions & 4 deletions eden/contrib/reviewstack/src/PullRequestChangeCount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,46 @@
* LICENSE file in the root directory of this source tree.
*/

import {gitHubPullRequest} from './recoil';
import {CounterLabel} from '@primer/react';
import {gitHubPullRequest, pullRequestSLOC} from './recoil';
import {CounterLabel, Tooltip} from '@primer/react';
import {useRecoilValue} from 'recoil';

export default function PullRequestChangeCount(): React.ReactElement | null {
const pullRequest = useRecoilValue(gitHubPullRequest);
const slocInfo = useRecoilValue(pullRequestSLOC);

if (pullRequest == null) {
return null;
}

const {additions, deletions} = pullRequest;
const {significantLines, generatedFileCount} = slocInfo;

const tooltipText =
generatedFileCount > 0
? `${significantLines} significant lines (excludes ${generatedFileCount} generated file${generatedFileCount === 1 ? '' : 's'})`
: `${significantLines} significant lines`;

return (
<>
<CounterLabel sx={{ backgroundColor: "success.muted" }}>+{additions}</CounterLabel>
<CounterLabel scheme="primary" sx={{ backgroundColor: "danger.muted", color: "black" }}>-{deletions}</CounterLabel>
<CounterLabel sx={{backgroundColor: 'success.muted'}}>+{additions}</CounterLabel>
<CounterLabel
scheme="primary"
sx={{backgroundColor: 'danger.muted', color: 'black'}}>
-{deletions}
</CounterLabel>
{significantLines > 0 && (
<Tooltip aria-label={tooltipText} direction="s">
<CounterLabel
sx={{
backgroundColor: 'accent.subtle',
color: 'fg.default',
marginLeft: 1,
}}>
{significantLines} sig
</CounterLabel>
</Tooltip>
)}
</>
);
}
Loading