Skip to content

Commit 2a231e4

Browse files
Merge upstream main into 1600
SQLAlchemy mapped-column inference overlapped with callable-kind and shaped-array cleanup. Retain only the new mapped-column identity and type-alias support while adopting main's current IntTuple-based shape imports.
2 parents 382206b + 62e2614 commit 2a231e4

926 files changed

Lines changed: 82862 additions & 24297 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.cargo/config.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ MARSHMALLOW_TEST_PATH = { value = "pyrefly/lib/test/marshmallow/third-party", re
1010
GLEAN_SNAPSHOTS_PATH = { value = "pyrefly/lib/report/glean/snapshots", relative = true }
1111
COVERAGE_TEST_PATH = { value = "pyrefly/lib/test/coverage/test_files", relative = true }
1212
STUBGEN_TEST_PATH = { value = "pyrefly/lib/test/stubgen", relative = true }
13-
SHAPE_DSL_TEST_PATH = { value = "tensor-shapes", relative = true }
13+
SHAPE_EXTENSIONS_TEST_PATH = { value = "tensor-shapes/pyrefly-shape-extensions", relative = true }

.claude/skills/modify-shaped-array-dsl/SKILL.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ live in **one file**, `crates/pyrefly_types/src/meta_shape_dsl.rs` (the binop
3838
arithmetic is `eval_binop`); the symbolic dim algebra it calls
3939
(`SizeExpr::add/sub/mul/floor_div`) is in `crates/pyrefly_types/src/dimension.rs`.
4040

41+
### Preserve tensor types in numeric formulas
42+
43+
Integer/float arithmetic overloads can sometimes cause a tensor expression to
44+
lose type information during overload selection. In tensor code, make formulas
45+
explicitly floating-point when the result is intended to remain a tensor. For
46+
example, multiply an exponent by `1.0`, or use a floating-point base such as
47+
`2.0` instead of `2`. These equivalent forms steer overload selection toward
48+
floating-point tensor arithmetic.
49+
4150
## You MUST unit-test the DSL logic, not just an example
4251

4352
An end-to-end example (`tensor-shapes/pyrefly-torch-stubs/examples`) exercises an op but does
@@ -61,6 +70,19 @@ After a DSL-kernel (Rust) change you must rebuild before the checker sees it:
6170
`buck build fbcode//pyrefly:pyrefly` (or `cargo build`). Stub-only `_shapes.pyi`
6271
edits need no rebuild.
6372

73+
For any DSL-kernel or broader Pyrefly core change that modifies shape
74+
manipulation semantics (as opposed to only editing torch/numpy stubs), the
75+
default verification gate is:
76+
77+
```bash
78+
tensor-shapes/run_all_shape_tests.py
79+
```
80+
81+
This gate runs the shape-relevant Rust unit tests plus the non-runtime
82+
tensor-shape corpus tests, and defaults to cargo with automatic buck fallback.
83+
Use `--mode buck` or `--mode cargo` when you need to pin the backend, and add
84+
`--include-runtime-tests` only when runtime coverage is relevant.
85+
6486
## Contributing the change
6587

6688
- **fbsource**: land as a diff.

.github/owners.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[
2+
"yangdanny97",
3+
"grievejia",
4+
"stroxler",
5+
"kinto0",
6+
"samwgoldman",
7+
"rchen152",
8+
"lolpack",
9+
"connernilsen",
10+
"maggiemoss",
11+
"javabster",
12+
"ndmitchell",
13+
"nathantempest"
14+
]
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
name: Assign imported PR to importer
2+
3+
on:
4+
issue_comment:
5+
types: [created]
6+
7+
permissions: {}
8+
9+
jobs:
10+
assign-importer:
11+
# issue_comment fires for both issues and PRs. Only act on PRs, and only on
12+
# the import bot's comment that records who imported the PR to Phabricator.
13+
# Gating on the bot author is also the security boundary: it stops an
14+
# arbitrary commenter from spoofing an "imported" notice to assign someone.
15+
if: >-
16+
${{ github.event.issue.pull_request != null
17+
&& github.event.comment.user.login == 'meta-codesync[bot]' }}
18+
runs-on: ubuntu-latest
19+
permissions:
20+
pull-requests: write
21+
steps:
22+
- name: Assign the importer named in the import comment
23+
uses: actions/github-script@v8
24+
with:
25+
github-token: ${{ secrets.GITHUB_TOKEN }}
26+
script: |
27+
// meta-codesync[bot] posts "@<handle> has **imported** this pull
28+
// request. ..." when a Meta employee imports the PR to Phabricator.
29+
// "imported" is bold in the raw markdown, so tolerate the "**". The
30+
// named importer is always a Meta employee, so there is no allowlist
31+
// to check — the bot-author gate above is the trust boundary, and
32+
// the verify step below drops anyone lacking repo access.
33+
const body = context.payload.comment.body || '';
34+
const match = body.match(/@([A-Za-z0-9-]+) has \**imported\** this pull request/);
35+
if (!match) {
36+
console.log('Comment is not an import notice, skipping');
37+
return;
38+
}
39+
const importer = match[1];
40+
41+
const issue_number = context.payload.issue.number;
42+
const { owner, repo } = context.repo;
43+
const assignees = (context.payload.issue.assignees || []).map(a => a.login);
44+
45+
if (assignees.includes(importer)) {
46+
console.log(`${importer} is already assigned to #${issue_number}`);
47+
return;
48+
}
49+
// Respect an existing manual assignment rather than clobbering it.
50+
if (assignees.length > 0) {
51+
console.log(`#${issue_number} already assigned to ${assignees.join(', ')}, leaving as-is`);
52+
return;
53+
}
54+
55+
await github.rest.issues.addAssignees({ owner, repo, issue_number, assignees: [importer] });
56+
57+
// GitHub silently ignores assignees who lack repo access, so verify
58+
// the assignment actually took effect.
59+
const { data: pr } = await github.rest.issues.get({ owner, repo, issue_number });
60+
if (!(pr.assignees || []).some(a => a.login === importer)) {
61+
console.log(`Could not assign ${importer} to #${issue_number} (no repo access?)`);
62+
return;
63+
}
64+
65+
console.log(`Assigned importer ${importer} to #${issue_number}`);
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
name: Nightly assign team owners to unassigned PRs
2+
3+
# Nightly sweep that assigns a Pyrefly team engineer (from .github/owners.json)
4+
# to open PRs that have no assignee, no merge conflict, and a signed CLA — the
5+
# PRs that are ready for a team member to shepherd but nobody has picked up.
6+
# Same logic as the on-demand backfill (assign_owners_sweep.yml), but runs
7+
# automatically once a day so newly-eligible PRs get picked up without anyone
8+
# having to trigger it.
9+
#
10+
# Selection is least-loaded (balanced against each owner's current open-PR
11+
# backlog). The picked owner is both assigned and requested as a reviewer (so
12+
# the PR shows up in their review queue), and each assignment is announced in
13+
# the team Discord channel.
14+
#
15+
# Scheduled runs assign for real.
16+
# Scheduled runs assign for real. A manual run from the Actions tab exposes a
17+
# "dry_run" toggle (default off) to preview without assigning.
18+
19+
on:
20+
schedule:
21+
- cron: "0 9 * * *"
22+
workflow_dispatch:
23+
inputs:
24+
dry_run:
25+
description: "Only log what would be assigned/notified, without assigning"
26+
type: boolean
27+
default: false
28+
29+
permissions: {}
30+
31+
jobs:
32+
assign-owners:
33+
runs-on: ubuntu-latest
34+
permissions:
35+
contents: read # read owners.json
36+
checks: read # read the Meta CLA check run
37+
issues: write # addAssignees is the issues API
38+
pull-requests: write
39+
steps:
40+
- name: Assign least-loaded owner to eligible open PRs
41+
uses: actions/github-script@v8
42+
env:
43+
DISCORD_WEBHOOK: ${{ secrets.DISCORD_ASSIGNMENTS_WEBHOOK_URL }}
44+
DISCORD_IDS: ${{ secrets.DISCORD_ASSIGNMENT_IDS }}
45+
with:
46+
github-token: ${{ secrets.GITHUB_TOKEN }}
47+
script: |
48+
// Scheduled runs have no inputs and always assign for real; a manual
49+
// run assigns unless its dry_run toggle is explicitly checked.
50+
const dryRun = (context.payload.inputs && context.payload.inputs.dry_run) === 'true';
51+
const { owner, repo } = context.repo;
52+
53+
// Pyrefly team GitHub handles — single source of truth in
54+
// .github/owners.json. Lower-cased for case-insensitive matching.
55+
const ownersFile = await github.rest.repos.getContent({
56+
owner, repo, path: '.github/owners.json',
57+
});
58+
const owners = JSON.parse(
59+
Buffer.from(ownersFile.data.content, 'base64').toString('utf8'),
60+
).map((o) => o.toLowerCase());
61+
if (owners.length === 0) {
62+
console.log('owners.json is empty, nothing to assign');
63+
return;
64+
}
65+
66+
// Round-robin pool = owners minus intentionally non-assignable
67+
// members: managers, hands-off maintainers, and anyone not
68+
// responsible for reviewing PRs. They stay in owners.json (still
69+
// team members for auto_assign.yml) but never receive sweep
70+
// assignments.
71+
const NOT_ASSIGNABLE = new Set(['lolpack', 'ndmitchell', 'javabster', 'yangdanny97']);
72+
const assignable = owners.filter((o) => !NOT_ASSIGNABLE.has(o));
73+
if (assignable.length === 0) {
74+
console.log('No assignable owners after exclusions, nothing to assign');
75+
return;
76+
}
77+
78+
// GitHub-handle -> numeric Discord user ID map for @-mentions. Kept
79+
// in the DISCORD_ASSIGNMENT_IDS secret, not a committed file: these
80+
// are personal Discord IDs and pyrefly is a public repo. An unset or
81+
// malformed secret means no one is pinged (posts are skipped).
82+
let discordIds = {};
83+
try {
84+
discordIds = JSON.parse(process.env.DISCORD_IDS || '{}');
85+
} catch (e) {
86+
console.log('DISCORD_ASSIGNMENT_IDS secret is not valid JSON; Discord posts will be skipped');
87+
}
88+
89+
const prs = await github.paginate(github.rest.pulls.list, {
90+
owner, repo, state: 'open', per_page: 100,
91+
});
92+
93+
// Seed per-owner load from PRs already assigned to each assignable
94+
// owner so least-loaded balances against the standing backlog, not
95+
// just this run's picks.
96+
const load = new Map(assignable.map((o) => [o, 0]));
97+
for (const pr of prs) {
98+
for (const a of pr.assignees || []) {
99+
const key = a.login.toLowerCase();
100+
if (load.has(key)) load.set(key, load.get(key) + 1);
101+
}
102+
}
103+
104+
// The CLA gate is a completed "meta-cla" check run on the PR head
105+
// SHA: conclusion "success" once signed, "action_required" while
106+
// unsigned. It is a check run, not a commit status. Use the newest
107+
// meta-cla run (re-runs append), falling back to a name match.
108+
const isClaSigned = async (sha) => {
109+
const checks = await github.paginate(github.rest.checks.listForRef, {
110+
owner, repo, ref: sha, per_page: 100,
111+
});
112+
const cla = checks
113+
.filter((c) => (c.app && c.app.slug === 'meta-cla') || /cla/i.test(c.name))
114+
.sort((a, b) => new Date(b.started_at || 0) - new Date(a.started_at || 0));
115+
return cla.length > 0 && cla[0].conclusion === 'success';
116+
};
117+
118+
// pulls.list omits mergeability; fetch the PR for it. GitHub computes
119+
// it asynchronously, so `mergeable` can be null right after a push —
120+
// re-fetch once, then treat a still-unknown result as "skip" rather
121+
// than risk assigning a conflicted PR. A true conflict is
122+
// mergeable === false / mergeable_state "dirty"; "blocked" (awaiting
123+
// checks/CLA/review) is NOT a conflict and must not be excluded.
124+
const hasNoConflict = async (number) => {
125+
for (let attempt = 0; attempt < 2; attempt++) {
126+
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number });
127+
if (pr.mergeable !== null) {
128+
return pr.mergeable === true && pr.mergeable_state !== 'dirty';
129+
}
130+
await new Promise((r) => setTimeout(r, 3000));
131+
}
132+
console.log(`#${number}: mergeability still unknown, skipping`);
133+
return false;
134+
};
135+
136+
// Discord embeds don't fire mentions, so the @-mention goes in the
137+
// top-level content while the embed carries the PR title/link
138+
// (matching notify_discord_prs.yml). A real mention needs the numeric
139+
// Discord user ID; without one we can't ping the assignee, so we log
140+
// and skip the post rather than sending a name that notifies nobody.
141+
const notify = async (assignee, pr) => {
142+
const discordId = discordIds[assignee];
143+
if (!discordId) {
144+
console.log(`#${pr.number}: no Discord ID mapped for ${assignee}, skipping Discord post`);
145+
return;
146+
}
147+
const title = `Assigned to Pyrefly PR #${pr.number}: ${pr.title}`;
148+
const truncatedTitle = title.length > 256 ? `${title.substring(0, 253)}...` : title;
149+
const payload = {
150+
content: `<@${discordId}> you've been assigned a PR to shepherd:`,
151+
embeds: [{
152+
title: truncatedTitle,
153+
url: pr.html_url,
154+
color: 16744448,
155+
fields: [{
156+
name: 'Assignee',
157+
value: `[${assignee}](https://github.com/${assignee})`,
158+
inline: true,
159+
}],
160+
}],
161+
};
162+
if (dryRun) {
163+
console.log(`[dry-run] would post to Discord: ${JSON.stringify(payload)}`);
164+
return;
165+
}
166+
const webhook = process.env.DISCORD_WEBHOOK;
167+
if (!webhook) {
168+
console.log('DISCORD_ASSIGNMENTS_WEBHOOK_URL secret not set, skipping notification');
169+
return;
170+
}
171+
const res = await fetch(webhook, {
172+
method: 'POST',
173+
headers: { 'Content-Type': 'application/json' },
174+
body: JSON.stringify(payload),
175+
});
176+
if (!res.ok) {
177+
console.log(`Discord POST failed for #${pr.number}: ${res.status} ${await res.text()}`);
178+
}
179+
};
180+
181+
let assigned = 0;
182+
for (const pr of prs) {
183+
if ((pr.assignees || []).length > 0) continue;
184+
if (pr.draft) continue; // drafts aren't ready for a shepherd yet
185+
186+
if (!(await isClaSigned(pr.head.sha))) {
187+
console.log(`#${pr.number}: CLA not signed, skipping`);
188+
continue;
189+
}
190+
if (!(await hasNoConflict(pr.number))) {
191+
console.log(`#${pr.number}: merge conflict / unknown mergeability, skipping`);
192+
continue;
193+
}
194+
195+
// Least-loaded assignable owner; ties break by owners.json order.
196+
const pick = assignable.reduce(
197+
(best, o) => (load.get(o) < load.get(best) ? o : best),
198+
assignable[0],
199+
);
200+
201+
if (!dryRun) {
202+
await github.rest.issues.addAssignees({
203+
owner, repo, issue_number: pr.number, assignees: [pick],
204+
});
205+
// GitHub silently drops assignees lacking repo access, so verify.
206+
const { data: after } = await github.rest.issues.get({ owner, repo, issue_number: pr.number });
207+
if (!(after.assignees || []).some((a) => a.login.toLowerCase() === pick)) {
208+
console.log(`#${pr.number}: could not assign ${pick} (no repo access?)`);
209+
continue;
210+
}
211+
// Also request them as a reviewer so the PR lands in their review
212+
// queue, not just their assigned list. GitHub rejects requesting
213+
// review from the PR author (422), so skip that case; a failure
214+
// here must not undo the assignment we just verified.
215+
if (pr.user.login.toLowerCase() === pick) {
216+
console.log(`#${pr.number}: ${pick} is the PR author, skipping review request`);
217+
} else {
218+
try {
219+
await github.rest.pulls.requestReviewers({
220+
owner, repo, pull_number: pr.number, reviewers: [pick],
221+
});
222+
} catch (e) {
223+
console.log(`#${pr.number}: could not request review from ${pick}: ${e.message}`);
224+
}
225+
}
226+
}
227+
228+
load.set(pick, load.get(pick) + 1);
229+
console.log(`${dryRun ? '[dry-run] would assign + request review from' : '#' + pr.number + ': assigned + requested review from'} ${pick}${dryRun ? ` to #${pr.number}` : ''} (load now ${load.get(pick)})`);
230+
await notify(pick, pr);
231+
assigned++;
232+
}
233+
234+
console.log(`Scanned ${prs.length} open PRs. ${dryRun ? 'Would assign' : 'Assigned'}: ${assigned}.`);

0 commit comments

Comments
 (0)