Skip to content

Commit 029114c

Browse files
committed
readme and ci
1 parent 7bc52b3 commit 029114c

File tree

5 files changed

+549
-42
lines changed

5 files changed

+549
-42
lines changed
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Generated by Array CLI - https://github.com/posthog/array
2+
# Blocks stacked PRs until their downstack dependencies are merged
3+
# Only runs for PRs managed by Array (detected via stack comment marker)
4+
5+
name: Stack Check
6+
7+
on:
8+
pull_request:
9+
types: [opened, synchronize, reopened, edited]
10+
pull_request_target:
11+
types: [closed]
12+
13+
jobs:
14+
check:
15+
runs-on: ubuntu-latest
16+
if: github.event_name == 'pull_request'
17+
steps:
18+
- name: Check stack dependencies
19+
uses: actions/github-script@v7
20+
with:
21+
script: |
22+
const pr = context.payload.pull_request;
23+
24+
// Check if this is an Array-managed PR by looking for stack comment
25+
const { data: comments } = await github.rest.issues.listComments({
26+
owner: context.repo.owner,
27+
repo: context.repo.repo,
28+
issue_number: pr.number
29+
});
30+
31+
const isArrayPR = comments.some(c =>
32+
c.body.includes('<!-- array-stack-comment -->')
33+
);
34+
35+
if (!isArrayPR) {
36+
console.log('Not an Array PR, skipping');
37+
return;
38+
}
39+
40+
const baseBranch = pr.base.ref;
41+
const trunk = ['main', 'master', 'develop'];
42+
43+
if (trunk.includes(baseBranch)) {
44+
console.log('Base is trunk, no dependencies');
45+
return;
46+
}
47+
48+
async function getBlockers(base, visited = new Set()) {
49+
if (trunk.includes(base) || visited.has(base)) {
50+
return [];
51+
}
52+
visited.add(base);
53+
54+
const { data: prs } = await github.rest.pulls.list({
55+
owner: context.repo.owner,
56+
repo: context.repo.repo,
57+
state: 'open',
58+
head: `${context.repo.owner}:${base}`
59+
});
60+
61+
if (prs.length === 0) {
62+
return [];
63+
}
64+
65+
const blocker = prs[0];
66+
const upstream = await getBlockers(blocker.base.ref, visited);
67+
return [{ number: blocker.number, title: blocker.title }, ...upstream];
68+
}
69+
70+
const blockers = await getBlockers(baseBranch);
71+
72+
if (blockers.length > 0) {
73+
const list = blockers.map(b => `#${b.number} (${b.title})`).join('\n - ');
74+
core.setFailed(`Blocked by:\n - ${list}\n\nMerge these PRs first (bottom to top).`);
75+
} else {
76+
console.log('All dependencies merged, ready to merge');
77+
}
78+
79+
recheck-dependents:
80+
runs-on: ubuntu-latest
81+
if: >-
82+
github.event_name == 'pull_request_target' &&
83+
github.event.action == 'closed' &&
84+
github.event.pull_request.merged == true
85+
steps:
86+
- name: Trigger recheck of dependent PRs
87+
uses: actions/github-script@v7
88+
with:
89+
script: |
90+
const pr = context.payload.pull_request;
91+
92+
// Check if this is an Array-managed PR
93+
const { data: comments } = await github.rest.issues.listComments({
94+
owner: context.repo.owner,
95+
repo: context.repo.repo,
96+
issue_number: pr.number
97+
});
98+
99+
const isArrayPR = comments.some(c =>
100+
c.body.includes('<!-- array-stack-comment -->')
101+
);
102+
103+
if (!isArrayPR) {
104+
console.log('Not an Array PR, skipping');
105+
return;
106+
}
107+
108+
const mergedBranch = pr.head.ref;
109+
110+
const { data: dependentPRs } = await github.rest.pulls.list({
111+
owner: context.repo.owner,
112+
repo: context.repo.repo,
113+
base: mergedBranch,
114+
state: 'open'
115+
});
116+
117+
for (const dependentPR of dependentPRs) {
118+
console.log(`Re-checking PR #${dependentPR.number}`);
119+
await github.rest.pulls.update({
120+
owner: context.repo.owner,
121+
repo: context.repo.repo,
122+
pull_number: dependentPR.number,
123+
base: 'main'
124+
});
125+
}

packages/cli/README.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
> [!IMPORTANT]
2+
> `arr` is still in development and not production-ready. Interested? Email [email protected]
3+
4+
# arr
5+
6+
A CLI for stacked PR management using Jujutsu (`jj`).
7+
8+
Split your work into small changes, push them as a PR stack, and keep everything in sync.
9+
10+
## Features
11+
12+
- Stacked PRs synced to GitHub
13+
- Simpler interface to `jj` for managing your work.
14+
- Visual stack log with PR status.
15+
- Comes with a GitHub Action to enforce merge order
16+
- Unknown commands pass through to `jj`
17+
18+
## Why
19+
20+
Stacked PRs keep reviews small and manageable. Managing them with `git` is painful, this involves rebasing, force-pushing, updating PR bases and describing the PR stack via comments.
21+
22+
`arr` makes it easy to create, manage and submit (stacked) PRs by using `jj` under the hood.
23+
24+
## Usage
25+
26+
```
27+
arr init # set up arr in a git repo
28+
arr create "message" # new change on stack
29+
arr submit # push stack, create PRs
30+
arr sync # fetch, rebase, cleanup merged
31+
arr up / arr down # navigate stack
32+
arr log # show stack
33+
arr exit # back to git
34+
arr help --all # show all commands
35+
```
36+
37+
## Workflow
38+
39+
```
40+
# start work
41+
arr create "add user model"
42+
# edit files - jj tracks automatically
43+
arr create "add user API"
44+
# edit more
45+
arr create "add user tests"
46+
47+
# push all 3 as chained PRs
48+
arr submit
49+
50+
# after reviews, sync with trunk
51+
arr sync
52+
53+
# navigate your stack
54+
arr up # go to child
55+
arr down # go to parent
56+
arr log # see where you are
57+
```
58+
59+
Each change becomes a PR. PRs are chained so reviewers see the dependency.
60+
61+
## CI
62+
63+
```
64+
arr ci
65+
```
66+
67+
Adds a GitHub Action that blocks merging a PR if its parent PR hasn't merged yet, which helps keep your stack in order.
68+
69+
## FAQ
70+
71+
**Can I use this with an existing `git` repo?**
72+
73+
Yes. Run `arr init` in any `git` repo. `jj` works alongside `git` - your `.git` folder stays intact.
74+
75+
**Do my teammates need to use `arr`?**
76+
77+
No. Your PRs are normal GitHub PRs. Teammates review and merge them as usual.
78+
79+
**What if I want to stop using `arr`?**
80+
81+
Run `arr exit` to switch back to `git`. Your repo, branches, and PRs stay exactly as they are. No lock-in.
82+
83+
**How is `arr` related to Array?**
84+
85+
`arr` is the CLI component of Array, an agentic development environment.
86+
87+
## Learn more
88+
89+
- [`jj` documentation](https://jj-vcs.github.io/jj/latest/) - full `jj` reference
90+
- [`jj` tutorial](https://jj-vcs.github.io/jj/latest/tutorial/) - getting started with `jj`

packages/cli/src/commands/ci.ts

Lines changed: 97 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,127 @@
11
import {
2+
checkRulesetExists,
23
ciWorkflowExists,
4+
enableStackCheckProtection,
35
getBranchProtectionUrl,
46
getRepoInfoFromRemote,
7+
JJ,
58
setupCI,
69
shellExecutor,
710
} from "@array/core";
8-
import { cyan, dim, formatSuccess, yellow } from "../utils/output";
11+
import { cyan, dim, formatError, formatSuccess, yellow } from "../utils/output";
12+
import { confirm } from "../utils/prompt";
913

1014
export async function ci(): Promise<void> {
1115
const cwd = process.cwd();
1216

17+
// Create workflow file if not exists
1318
if (ciWorkflowExists(cwd)) {
14-
console.log(yellow("Stack check workflow already exists"));
15-
console.log(dim(" .github/workflows/array-stack-check.yml"));
19+
console.log(dim("Stack check workflow already exists"));
20+
} else {
21+
const result = setupCI(cwd);
22+
if (result.created) {
23+
console.log(
24+
formatSuccess("Created .github/workflows/array-stack-check.yml"),
25+
);
26+
}
27+
}
28+
29+
const repoInfo = await getRepoInfo(cwd);
30+
if (!repoInfo) {
1631
console.log();
17-
await printInstructions(cwd);
32+
console.log(yellow("Could not determine repository."));
33+
console.log(
34+
dim(
35+
"Manually add 'Stack Check' as a required status check in GitHub settings.",
36+
),
37+
);
1838
return;
1939
}
2040

21-
const result = setupCI(cwd);
41+
console.log();
2242

23-
if (result.created) {
24-
console.log(formatSuccess("Created .github/workflows/array-stack-check.yml"));
25-
console.log();
26-
await printInstructions(cwd);
43+
// Check if ruleset already exists to show appropriate prompt
44+
const rulesetExists = await checkRulesetExists(
45+
repoInfo.owner,
46+
repoInfo.repo,
47+
shellExecutor,
48+
cwd,
49+
);
50+
51+
const prompt = rulesetExists
52+
? "Update ruleset to latest? (needs admin access)"
53+
: "Enable 'Stack Check' as required? (needs admin access)";
54+
55+
const shouldProceed = await confirm(prompt);
56+
57+
if (shouldProceed) {
58+
const success = await tryEnableProtection(cwd, repoInfo, rulesetExists);
59+
if (success) return;
2760
}
61+
62+
// Show manual URL if they declined or API failed
63+
console.log();
64+
const url = getBranchProtectionUrl(repoInfo.owner, repoInfo.repo);
65+
console.log("To enable manually, create a ruleset:");
66+
console.log(cyan(` ${url}`));
67+
console.log(
68+
dim(" → Add 'Require status checks' → Type 'Stack Check' → Create"),
69+
);
2870
}
2971

30-
async function printInstructions(cwd: string): Promise<void> {
72+
async function getRepoInfo(
73+
cwd: string,
74+
): Promise<{ owner: string; repo: string } | null> {
3175
const remoteResult = await shellExecutor.execute(
3276
"git",
3377
["config", "--get", "remote.origin.url"],
3478
{ cwd },
3579
);
3680

37-
if (remoteResult.exitCode === 0) {
38-
const repoInfo = getRepoInfoFromRemote(remoteResult.stdout.trim());
39-
if (repoInfo.ok) {
40-
const url = getBranchProtectionUrl(repoInfo.value.owner, repoInfo.value.repo);
41-
console.log("To enforce stack ordering, add 'Stack Check' as a required check:");
42-
console.log(cyan(` ${url}`));
43-
console.log(dim(" → Edit rule for 'main' → Require status checks → Add 'Stack Check'"));
44-
return;
81+
if (remoteResult.exitCode !== 0) return null;
82+
83+
const repoInfo = getRepoInfoFromRemote(remoteResult.stdout.trim());
84+
return repoInfo.ok ? repoInfo.value : null;
85+
}
86+
87+
async function tryEnableProtection(
88+
cwd: string,
89+
repoInfo: { owner: string; repo: string },
90+
isUpdate: boolean,
91+
): Promise<boolean> {
92+
const jj = new JJ({ cwd });
93+
const trunk = jj.getTrunk();
94+
95+
console.log(
96+
dim(isUpdate ? "Updating ruleset..." : `Creating ruleset for ${trunk}...`),
97+
);
98+
99+
const result = await enableStackCheckProtection(
100+
{ owner: repoInfo.owner, repo: repoInfo.repo, trunk },
101+
shellExecutor,
102+
cwd,
103+
);
104+
105+
const rulesetsUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/rules`;
106+
107+
if (result.success) {
108+
if (result.updated) {
109+
console.log(formatSuccess(`Updated ruleset 'Array Stack Check'`));
110+
} else if (result.alreadyEnabled) {
111+
console.log(formatSuccess(`Ruleset 'Array Stack Check' already exists`));
112+
} else {
113+
console.log(formatSuccess(`Created ruleset 'Array Stack Check'`));
45114
}
115+
console.log();
116+
console.log(
117+
`PRs in a stack will now be blocked until their downstack PRs are merged.`,
118+
);
119+
console.log();
120+
console.log(`View or edit the ruleset:`);
121+
console.log(cyan(` ${rulesetsUrl}`));
122+
return true;
46123
}
47124

48-
console.log("To enforce stack ordering:");
49-
console.log(dim(" 1. Go to Repo Settings → Branches → Branch protection rules"));
50-
console.log(dim(" 2. Edit rule for 'main' (or create one)"));
51-
console.log(dim(" 3. Enable 'Require status checks to pass'"));
52-
console.log(dim(" 4. Search for 'Stack Check' and add it"));
125+
console.log(formatError(result.error ?? "Failed to create ruleset"));
126+
return false;
53127
}

0 commit comments

Comments
 (0)