Skip to content

Commit 42c0ea4

Browse files
authored
Merge branch 'main' into octo/20260801-text-to-image-tool-recvqqFfIZ8U41
2 parents 434d911 + 5e97fc8 commit 42c0ea4

2,218 files changed

Lines changed: 498188 additions & 76726 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.

.github/CODEOWNERS

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
# ============================================================
44

55
# --- CODEOWNERS file itself ---
6-
/.github/CODEOWNERS @pomelo-nwu
6+
/.github/CODEOWNERS @pomelo-nwu @wenshao
77

88
# --- Core package ---
9-
/packages/core/ @wenshao @tanzhenxin @yiliang114 @LaZzyMan
9+
/packages/core/ @wenshao @tanzhenxin @yiliang114 @LaZzyMan @doudouOUC
1010

1111
# --- CUA Driver & Mobile MCP ---
1212
/packages/cua-driver/ @LaZzyMan
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: 'Configure Windows self-hosted runner'
2+
description: 'Tunes a self-hosted Windows runner for the test gates: exports the Linux gates'' C.UTF-8 locale env (inert on Windows, where Node collates through ICU), redirects TEMP/TMP to RUNNER_TEMP, and puts Git Bash on PATH for bash-shell steps. Runs after actions/checkout because repository-local actions resolve from the workspace; callers turn off autocrlf before the checkout itself. Shared by the Windows merge-queue gate and the runner smoke workflow so both always run an identical configuration.'
3+
4+
runs:
5+
using: 'composite'
6+
steps:
7+
- name: 'Configure Windows test environment'
8+
# Self-hosted runners have Windows PowerShell, not necessarily pwsh.
9+
# Force UTF-8 so GITHUB_ENV/GITHUB_PATH are not written as UTF-16LE.
10+
shell: 'powershell'
11+
run: |-
12+
$ErrorActionPreference = 'Stop'
13+
"TEMP=$env:RUNNER_TEMP" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
14+
"TMP=$env:RUNNER_TEMP" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
15+
"LC_ALL=C.UTF-8" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
16+
$gitBash = 'C:\Program Files\Git\bin'
17+
if (!(Test-Path $gitBash)) {
18+
Write-Output "::error::Git Bash not found at $gitBash"
19+
exit 1
20+
}
21+
$gitBash | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
name: 'Self-hosted Node.js preflight'
2+
description: 'Preflight for self-hosted runners that reuse their pre-installed Node.js instead of downloading one on ECS, where nodejs.org may be unreachable through the egress proxy: fails the job when Node is missing and warns when the major version is not 22.'
3+
4+
runs:
5+
using: 'composite'
6+
steps:
7+
- name: 'Use pre-installed Node.js'
8+
shell: 'bash'
9+
run: |-
10+
if ! command -v node >/dev/null 2>&1; then
11+
echo "::error::Node.js is not on PATH for this self-hosted runner. Provision Node 22.x or set the MAINTAINER_ECS_RUNNER_DISABLED repository variable to 'true' to route this gate back to hosted runners."
12+
exit 1
13+
fi
14+
echo "Using pre-installed Node $(node -v) / npm $(npm -v)"
15+
if [[ "$(node -p 'process.versions.node.split(".")[0]')" != "22" ]]; then
16+
echo "::warning::Expected Node 22.x but found $(node -v); tests will run against the runner's Node."
17+
fi
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
name: 'Verify Checkout Head'
2+
description: 'Fails the job when the checked-out tree does not contain the expected head commit, so a stale checkout (e.g. a caching egress proxy serving an old ref) is never silently tested into a merge. Cheap: one merge-base, sub-second.'
3+
4+
inputs:
5+
expected_sha:
6+
description: 'Commit SHA that must be an ancestor of the checked-out HEAD.'
7+
required: true
8+
9+
runs:
10+
using: 'composite'
11+
steps:
12+
- name: 'Verify checkout includes expected head commit'
13+
shell: 'bash'
14+
env:
15+
EXPECTED_SHA: '${{ inputs.expected_sha }}'
16+
run: |-
17+
if ! git merge-base --is-ancestor "${EXPECTED_SHA}" HEAD; then
18+
echo "::error::Checked out ref does not contain expected head ${EXPECTED_SHA}."
19+
git log --oneline --decorate -5
20+
exit 1
21+
fi
278 KB
Loading
466 KB
Loading
749 KB
Loading

.github/issue-owners.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"$comment": "Label-driven issue assignment map; areas are keyed on the existing issue label taxonomy. Assignment is a pure function of an issue's labels — no model output is involved. Owners need push access but do NOT need a CODEOWNERS entry; every candidate is re-checked against the collaborator API at write time, so adding a login here cannot grant access to someone who lacks it. Areas match in file order, first match wins. See docs/design/2026-08-07-issue-auto-assignment.md.",
3+
"requireLabels": ["need-discussion"],
4+
"skipLabels": [
5+
"welcome-pr",
6+
"feature/need-help",
7+
"good first issue",
8+
"help wanted",
9+
"autofix/approved",
10+
"autofix/in-progress"
11+
],
12+
"areas": [
13+
{
14+
"name": "core",
15+
"labels": ["category/core", "scope/core"],
16+
"owners": [
17+
"wenshao",
18+
"tanzhenxin",
19+
"yiliang114",
20+
"LaZzyMan",
21+
"doudouOUC",
22+
"pomelo-nwu",
23+
"DennisYu07",
24+
"jifeng",
25+
"chiga0",
26+
"qqqys",
27+
"ytahdn",
28+
"BenGuanRan"
29+
]
30+
}
31+
]
32+
}
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
#!/usr/bin/env node
2+
// Assign an issue to an area owner, derived purely from the issue's labels.
3+
//
4+
// This script never reads issue title, body, or comments, so untrusted issue
5+
// text cannot steer the assignment. The triage agent's only influence is the
6+
// labels it applies, drawn from the repository's existing label taxonomy; the
7+
// label -> owner map lives in .github/issue-owners.json and is reviewed like
8+
// any other checked-in file. Push access is re-verified against the live
9+
// collaborator API before every write, so an edit to that map cannot assign
10+
// someone who does not already have permission.
11+
import { appendFileSync, readFileSync } from 'node:fs';
12+
import { spawnSync } from 'node:child_process';
13+
import { pathToFileURL } from 'node:url';
14+
15+
const OWNERS_FILE = '.github/issue-owners.json';
16+
const WRITE_PERMISSIONS = new Set(['admin', 'maintain', 'write']);
17+
const LOGIN = /^(?!.*--)[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/;
18+
19+
function isStringArray(value) {
20+
return Array.isArray(value) && value.every((v) => typeof v === 'string');
21+
}
22+
23+
export function loadPolicy(raw) {
24+
const policy = JSON.parse(raw);
25+
if (!policy || typeof policy !== 'object' || Array.isArray(policy)) {
26+
throw new Error(`${OWNERS_FILE}: not an object`);
27+
}
28+
// An empty label entry can never match; in requireLabels it would silently
29+
// skip every issue on a green run, so reject it like other malformed config.
30+
if (
31+
!isStringArray(policy.requireLabels) ||
32+
!isStringArray(policy.skipLabels) ||
33+
policy.requireLabels.some((label) => label.length === 0) ||
34+
policy.skipLabels.some((label) => label.length === 0)
35+
) {
36+
throw new Error(
37+
`${OWNERS_FILE}: requireLabels/skipLabels must be non-empty strings`,
38+
);
39+
}
40+
if (!Array.isArray(policy.areas) || policy.areas.length === 0) {
41+
throw new Error(`${OWNERS_FILE}: areas must be a non-empty array`);
42+
}
43+
const areaNames = new Set();
44+
for (const area of policy.areas) {
45+
if (typeof area?.name !== 'string' || area.name.length === 0) {
46+
throw new Error(`${OWNERS_FILE}: every area needs a name`);
47+
}
48+
// First match wins, so two areas sharing a name silently shadow one another.
49+
if (areaNames.has(area.name)) {
50+
throw new Error(`${OWNERS_FILE}: duplicate area ${area.name}`);
51+
}
52+
areaNames.add(area.name);
53+
if (
54+
!isStringArray(area.labels) ||
55+
area.labels.length === 0 ||
56+
area.labels.some((label) => label.length === 0)
57+
) {
58+
throw new Error(`${OWNERS_FILE}: area ${area.name} needs labels`);
59+
}
60+
if (!Array.isArray(area.owners) || area.owners.length === 0) {
61+
throw new Error(`${OWNERS_FILE}: area ${area.name} needs owners`);
62+
}
63+
const seen = new Set();
64+
for (const owner of area.owners) {
65+
// Rejected here rather than at the gh call so a typo fails the config,
66+
// not a single assignment attempt.
67+
if (typeof owner !== 'string' || !LOGIN.test(owner)) {
68+
throw new Error(`${OWNERS_FILE}: invalid login in ${area.name}`);
69+
}
70+
// A repeated login would be counted twice and win ties unfairly.
71+
const normalizedOwner = owner.toLowerCase();
72+
if (seen.has(normalizedOwner)) {
73+
throw new Error(`${OWNERS_FILE}: duplicate owner ${owner}`);
74+
}
75+
seen.add(normalizedOwner);
76+
}
77+
}
78+
return policy;
79+
}
80+
81+
// Returns a human-readable reason to skip, or null to proceed. Ordered so the
82+
// most informative reason wins when several apply.
83+
export function skipReason(policy, issue) {
84+
const labels = new Set(issue.labels.map((label) => label.name));
85+
if (issue.state !== 'OPEN') return 'issue is not open';
86+
if (issue.assignees.length > 0) return 'issue already has an assignee';
87+
const skipped = policy.skipLabels.filter((label) => labels.has(label));
88+
if (skipped.length > 0) return `carries ${skipped.join(', ')}`;
89+
const missing = policy.requireLabels.filter((label) => !labels.has(label));
90+
if (missing.length > 0) return `missing ${missing.join(', ')}`;
91+
return null;
92+
}
93+
94+
// First matching area wins, so file order is the documented precedence.
95+
export function matchArea(policy, issue) {
96+
const labels = new Set(issue.labels.map((label) => label.name));
97+
return (
98+
policy.areas.find((area) =>
99+
area.labels.some((label) => labels.has(label)),
100+
) ?? null
101+
);
102+
}
103+
104+
// Rotate by issue number before the stable minimum so a set of equally loaded
105+
// owners spreads round-robin instead of always landing on the first entry.
106+
export function pickOwner(owners, loadByOwner, issueNumber) {
107+
const offset = issueNumber % owners.length;
108+
const rotated = [...owners.slice(offset), ...owners.slice(0, offset)];
109+
return rotated.reduce((best, owner) =>
110+
loadByOwner.get(owner) < loadByOwner.get(best) ? owner : best,
111+
);
112+
}
113+
114+
function gh(args) {
115+
const result = spawnSync('gh', args, {
116+
encoding: 'utf8',
117+
maxBuffer: 10 * 1024 * 1024,
118+
});
119+
if (result.status !== 0) {
120+
throw new Error(result.stderr.trim() || `gh ${args.join(' ')} failed`);
121+
}
122+
return result.stdout.trim();
123+
}
124+
125+
function record(lines) {
126+
const body = `${lines.join('\n')}\n`;
127+
process.stdout.write(body);
128+
if (process.env.GITHUB_STEP_SUMMARY) {
129+
appendFileSync(process.env.GITHUB_STEP_SUMMARY, body);
130+
}
131+
}
132+
133+
// A candidate who lost push access, renamed, or deleted their account makes
134+
// the permission lookup fail; warn and drop them rather than failing the run
135+
// over one stale entry.
136+
function canWrite(repository, login) {
137+
try {
138+
return WRITE_PERMISSIONS.has(
139+
gh([
140+
'api',
141+
`repos/${repository}/collaborators/${login}/permission`,
142+
'--jq',
143+
'.permission',
144+
]),
145+
);
146+
} catch (error) {
147+
console.warn(
148+
`::warning::Cannot verify push access for @${login}: ${error.message}`,
149+
);
150+
return false;
151+
}
152+
}
153+
154+
function openIssueCount(repository, login) {
155+
return Number(
156+
gh([
157+
'issue',
158+
'list',
159+
'--repo',
160+
repository,
161+
'--state',
162+
'open',
163+
'--assignee',
164+
login,
165+
'--limit',
166+
'100',
167+
'--json',
168+
'number',
169+
'--jq',
170+
'length',
171+
]),
172+
);
173+
}
174+
175+
function main() {
176+
const repository = process.env.GITHUB_REPOSITORY;
177+
const issueNumber = Number(process.env.ISSUE_NUMBER);
178+
const dryRun = process.env.DRY_RUN === 'true';
179+
if (!repository || !/^[^/]+\/[^/]+$/.test(repository)) {
180+
throw new Error('Invalid repository');
181+
}
182+
if (!Number.isSafeInteger(issueNumber) || issueNumber < 1) {
183+
throw new Error('Invalid issue number');
184+
}
185+
186+
const policy = loadPolicy(readFileSync(OWNERS_FILE, 'utf8'));
187+
const issue = JSON.parse(
188+
gh([
189+
'issue',
190+
'view',
191+
String(issueNumber),
192+
'--repo',
193+
repository,
194+
'--json',
195+
'state,labels,assignees',
196+
]),
197+
);
198+
199+
const skip = skipReason(policy, issue);
200+
if (skip) {
201+
record([`Assignment: skipped — ${skip}`]);
202+
return;
203+
}
204+
205+
const area = matchArea(policy, issue);
206+
if (!area) {
207+
record(['Assignment: skipped — no area label matched']);
208+
return;
209+
}
210+
211+
const eligible = area.owners.filter((owner) => canWrite(repository, owner));
212+
if (eligible.length === 0) {
213+
console.warn(
214+
`::warning::No owner of area ${area.name} has push access; check ${OWNERS_FILE}.`,
215+
);
216+
record([`Assignment: skipped — no eligible owner for area ${area.name}`]);
217+
return;
218+
}
219+
220+
const loadByOwner = new Map(
221+
eligible.map((owner) => [owner, openIssueCount(repository, owner)]),
222+
);
223+
const assignee = pickOwner(eligible, loadByOwner, issueNumber);
224+
225+
if (dryRun) {
226+
record([
227+
`Area: ${area.name}`,
228+
`Assignment: dry-run — would assign @${assignee} (${loadByOwner.get(assignee)} open)`,
229+
]);
230+
return;
231+
}
232+
233+
const latestIssue = JSON.parse(
234+
gh([
235+
'issue',
236+
'view',
237+
String(issueNumber),
238+
'--repo',
239+
repository,
240+
'--json',
241+
'state,labels,assignees',
242+
]),
243+
);
244+
const latestSkip = skipReason(policy, latestIssue);
245+
if (latestSkip) {
246+
record([`Assignment: skipped — ${latestSkip}`]);
247+
return;
248+
}
249+
if (matchArea(policy, latestIssue)?.name !== area.name) {
250+
record(['Assignment: skipped — issue labels changed']);
251+
return;
252+
}
253+
254+
gh([
255+
'issue',
256+
'edit',
257+
String(issueNumber),
258+
'--repo',
259+
repository,
260+
'--add-assignee',
261+
assignee,
262+
]);
263+
record([
264+
`Area: ${area.name}`,
265+
`Assignment: assigned @${assignee} (${loadByOwner.get(assignee)} open)`,
266+
]);
267+
}
268+
269+
if (
270+
process.argv[1] &&
271+
import.meta.url === pathToFileURL(process.argv[1]).href
272+
) {
273+
try {
274+
main();
275+
} catch (error) {
276+
console.error(error instanceof Error ? error.message : String(error));
277+
process.exit(1);
278+
}
279+
}

0 commit comments

Comments
 (0)