From afa1c48445379fbe3ec9972afd12adfd5c65671d Mon Sep 17 00:00:00 2001 From: zhaozhaozz <6450679+zhaozhaozz@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:17:17 +0000 Subject: [PATCH 1/2] Show merge queue checks in a focused five-lane CI history --- docs/branch-lines.md | 6 ++-- docs/ci-history-lanes.md | 14 ++++----- docs/dashboard-pages.md | 2 +- gsb/ci_lanes.py | 29 +++++++++--------- gsb/incremental_project.py | 6 ++-- site/app.js | 17 ++++++----- site/index.html | 8 ++--- static/app.js | 17 ++++++----- static/index.html | 8 ++--- test/journey-board.spec.cjs | 49 +++++++++++++++++++----------- test/prepare-site.py | 7 +++-- tests/test_ci_lanes.py | 59 +++++++++++++++++++------------------ tests/test_lines.py | 36 ++++++++++++++-------- 13 files changed, 145 insertions(+), 113 deletions(-) diff --git a/docs/branch-lines.md b/docs/branch-lines.md index c94c48a84..0e4366b68 100644 --- a/docs/branch-lines.md +++ b/docs/branch-lines.md @@ -6,7 +6,7 @@ - 每个 run 归入它的工作所针对的分支:PR 运行归入 PR 的目标分支,push、手动、定时运行归入运行所在分支。 - 目标分支不是任何已配置分支线的 run(版本 tag、其他分支的 push、无法匹配 PR 的运行)归入默认分支线,因此每个 run 只计一次。Nightly 按运行所在分支归线,手动在 release 分支执行也归 release 线。 -- 每条分支线都展示窗口内归入该线的全部已采集 run:PR、分支门禁、Daily、版本、工作流调用、其他工作流、未知触发七层;`workflow_call` 子 run 按各自 run ID 单独显示,不被父 run 替代。 +- 每条分支线按 PR、分支门禁、合并队列、Daily、版本五层展示;`merge_group` 优先归入合并队列。工作流调用、其他工作流与无事件且无关联 PR 的门禁 run 不画点,也不通过脚注或最近 run 表另列;采集仍保存全部 run。 ## 使用 @@ -33,7 +33,7 @@ | 页面 | 内容 | | --- | --- | -| CI | 该分支 push / 手动门禁和目标为它的 PR 的成功率、包含全部运行类别的分层历史、Workflow 与 Job 健康、最近 run;门禁层显示完整分支名,不标成“主干” | +| CI | 该分支 push / 手动门禁和目标为它的 PR 的成功率、上述五层历史、Workflow 与 Job 健康、最近 run;门禁层显示完整分支名,不标成“主干” | | 测试 | 最近用例数,以及该分支最新 push / 手动运行冻结的[标签化测试](tagged-tests.md)目录和规则;没有运行过的组合不显示 | | Coverage | 只用该分支线运行上传的覆盖率摘要;完整基线来自该分支完整通过的 push 或手动门禁;PR 列表只含目标为它的 PR | @@ -63,4 +63,4 @@ PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_lines.p node .e2e/node_modules/playwright/cli.js test --config test/playwright.config.cjs -g "CI, tests and coverage" ``` -单元测试覆盖配置校验、同仓 / fork / 手动 / tag 运行的归线、release 手动 Nightly 与 legacy Daily 可见、工作流调用和其他工作流不被排除、旧分层快照重建、各线成功率、覆盖率产物归线、标签化目录分线与迁移。浏览器用例在桌面和窄屏验证切换、跨页与刷新后保持、Daily/调用/其他运行链接、各线数据不混合,以及总览列出其他分支线的失败。 +单元测试覆盖配置校验、同仓 / fork / 手动 / tag 运行的归线、release 手动 Nightly 与 legacy Daily 可见、合并队列优先归类、工作流调用和其他工作流不展示但保留记录、旧分层快照重建、各线成功率、覆盖率产物归线、标签化目录分线与迁移。浏览器用例在桌面和窄屏验证切换、跨页与刷新后保持、Daily 与合并队列链接、空合并队列、各线数据不混合,以及总览列出其他分支线的失败。 diff --git a/docs/ci-history-lanes.md b/docs/ci-history-lanes.md index c3e41caa1..52edd8692 100644 --- a/docs/ci-history-lanes.md +++ b/docs/ci-history-lanes.md @@ -2,21 +2,19 @@ ## 功能 -CI 页的「CI 分层历史」按触发方式展示该分支线窗口内的全部已采集 Actions run,七层共用一条日期横轴: +CI 页的「CI 分层历史」按触发方式展示该分支线窗口内的 PR、分支门禁、合并队列、Daily 与版本运行,五层共用一条日期横轴: | 层 | 归入的 run | | --- | --- | | PR | 门禁工作流(默认名称 `^CI$`)的 `pull_request` / `pull_request_target` | | 主干 / 完整分支名 | 门禁工作流在该分支线上的 `push`,以及它自己的 `workflow_dispatch`(提示中标“手动”) | +| 合并队列 | 所有 `event=merge_group` 的 run,优先于工作流名称规则 | | Daily | 名称匹配 daily 规则(Nightly)的 run,包括 `schedule` 和手动 `workflow_dispatch` | | 版本 | 名称匹配 release 规则(Release)的 run,包括版本 tag 推送或手动运行 | -| 工作流调用 | `event=workflow_call` 的 run,以及门禁工作流带有其他调用事件(如 `schedule`)的 run | -| 其他工作流 | 不匹配上述名称规则的工作流,以及归入该线但不匹配其门禁层的分支事件 | -| 未知触发 | 缺少触发事件、且无法通过关联 PR 归类的门禁 run | [分支线](branch-lines.md)的每条线都有上述完整分层。非默认分支的 push / 手动门禁层使用完整分支名;例如在 `releases/v0.3.0.beta` 手动运行 Nightly,会出现在该分支线的 Daily 层。 -名称规则取 `board-config.json` 的 `workflows.gate` / `daily` / `release`,缺省时使用相同默认值。分层不会排除其他类别:若 GitHub 返回独立的 `workflow_call` 子 run,就按其 run ID 在工作流调用层单独画点。仅属于父 run 的 job 不会被虚构成新 run;同一 run 的重试仍只展示最后一次尝试。其他工作流和无法判断触发方式的旧记录也各有显示层,不再只留脚注计数。 +`merge_group` 优先归入合并队列;其余名称规则取 `board-config.json` 的 `workflows.gate` / `daily` / `release`,缺省时使用相同默认值。`workflow_call` 子 run、其他工作流、缺少事件且没有关联 PR 的门禁 run,以及不属于该线门禁层的分支事件,不在分层图或最近 run 表中展示,也不追加脚注计数。采集仍保存所有 run,不过滤 Actions 查询;同一 run 的重试只展示最后一次尝试。没有事件但有关联 PR 的门禁 run 仍归 PR。 ## 使用 @@ -34,8 +32,8 @@ CI 页的「CI 分层历史」按触发方式展示该分支线窗口内的全 - `gsb/ci_lanes.py`:`lane_of` 分层,`pr_number` 取 PR 号,`duration` 取每次 run 的耗时(记录中的 `duration_s`,否则为开始到最后更新的时间),`build_lanes` 生成 `sections.ci.data.lanes`,每格带 `duration_s`,每层摘要带 `median_duration_s`。`days` 是共同日期轴,每层 `days[i]` 与之对齐,格内已按时间排序。前端 `static/app.js` 的 `ciLanes` 只负责渲染。 - PR 号优先使用 run 的 `pull_requests`。fork PR 的该字段为空,改为按 head 分支匹配当时开放的 PR,再用 head 仓库、SHA、标题排除歧义;仍不唯一时不显示号码。推断出的号码在提示中标“按分支推断”。 - 增量模式(`incremental_project.build_snapshot`)只为窗口内的 run 读取完整记录,并只读取窗口内开放过的 PR。兼容模式(`project.build_project`)使用本次读取的 run 页。 -- run 与 PR 记录新增 `head_repo`,历史搜索索引新增 `event`。旧索引行缺少 `event` 时,快照从对应记录重建该行一次,不改记录、不删除历史;新字段随增量采集与每周对账补齐。既无触发事件又无关联 PR 的门禁 run 显示在“未知触发”。 -- 已发布快照没有 `lanes`,或任一分支线缺少完整分层时,下一次构建从已有记录重建快照;不改变 Actions 查询,也不重新下载产物。 +- run 与 PR 记录新增 `head_repo`,历史搜索索引新增 `event`。旧索引行缺少 `event` 时,快照从对应记录重建该行一次,不改记录、不删除历史;新字段随增量采集与每周对账补齐。既无触发事件又无关联 PR 的门禁 run 不画点。 +- 已发布快照没有 `lanes`,或任一分支线不是上述五层顺序时,下一次构建从已有记录重建快照;不改变 Actions 查询,也不重新下载产物。 ## 边界 @@ -52,4 +50,4 @@ PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -p 'test_ci_lane node .e2e/node_modules/playwright/cli.js test --config test/playwright.config.cjs -g "CI trends" ``` -单元测试覆盖完整分层(PR、分支 push、Nightly 定时与手动、Release tag、独立 `workflow_call`、其他工作流、未知触发)、run ID 去重、同一天多次 run 的纵向顺序、各层日期对齐、PR 号推断以及旧索引缺字段降级。浏览器用例检查七层与 30 列对齐、同日纵向顺序、提示和链接、空层、窄屏滚动与标签固定。 +单元测试覆盖五层分类与隐藏类别(合并队列优先、PR、分支 push、Nightly 定时与手动、Release tag,以及 `workflow_call`、其他工作流、无事件无关联 PR 不画点)、run ID 去重、同一天多次 run 的纵向顺序、各层日期对齐、PR 号推断以及旧索引缺字段降级。浏览器用例检查五层与 30 列对齐、同日纵向顺序、合并队列提示和链接、空队列、窄屏滚动与标签固定。 diff --git a/docs/dashboard-pages.md b/docs/dashboard-pages.md index f1024b4a1..a72b7b7a8 100644 --- a/docs/dashboard-pages.md +++ b/docs/dashboard-pages.md @@ -18,7 +18,7 @@ CI、PR、Issue 数据不可读取时显示“状态未知”,不会显示为正常。较低优先级的提示(标签化用例从未覆盖、Issue 零回复或陈旧、大量未发布提交、默认分支未保护)折叠在“其他提示”中。 -**流水线**(默认分支线):PR 门禁、主干、每日构建、版本构建四张卡片,给出最近一次运行的结论、时间、工作流、耗时和近 30 天成功率。下方是近 14 天的分层图,与 CI 页同一数据,[CI 分层历史](ci-history-lanes.md)有详细说明。 +**流水线**(默认分支线):PR 门禁、主干、每日构建、版本构建四张卡片,给出最近一次运行的结论、时间、工作流、耗时和近 30 天成功率。下方是近 14 天的 PR、主干、合并队列、Daily、版本五层图,与 CI 页同一数据,[CI 分层历史](ci-history-lanes.md)有详细说明。 **交付与质量**: diff --git a/gsb/ci_lanes.py b/gsb/ci_lanes.py index 7b871d667..4ccc75bb3 100644 --- a/gsb/ci_lanes.py +++ b/gsb/ci_lanes.py @@ -1,4 +1,4 @@ -"""All collected CI runs grouped by trigger on one calendar. +"""Selected CI trigger lanes on one calendar. Each lane follows how the source repository triggers its Actions. All lanes share the same day axis in one fixed display zone; runs of one day stay in @@ -14,8 +14,7 @@ WINDOW_DAYS = 30 # The team reads the board in Asia/Shanghai, which has no daylight saving. DISPLAY_ZONE = timezone(timedelta(hours=8), "UTC+8") -LANES = (("pr", "PR"), ("main", "主干"), ("daily", "Daily"), ("release", "版本"), - ("called", "工作流调用"), ("other", "其他工作流"), ("unknown", "未知触发")) +LANES = (("pr", "PR"), ("main", "主干"), ("merge_queue", "合并队列"), ("daily", "Daily"), ("release", "版本")) # Mirrors board-config.json so a site without that section still classifies runs. DEFAULT_RULES = {"gate": ["^CI$"], "daily": ["nightly", "daily", "每日"], "release": ["release", "version", "版本"]} PR_EVENTS = ("pull_request", "pull_request_target") @@ -69,30 +68,32 @@ def duration(run): def lane_of(run, rules=None, default_branch="main"): - """Return a lane key, "called" for reusable-workflow children, "unknown" or "other".""" + """Return a displayed lane key, or None for a run outside these five lanes.""" rules = {**DEFAULT_RULES, **{kind: patterns for kind, patterns in (rules or {}).items() if patterns}} name, event = run.get("name") or "", run.get("event") matches = lambda kind: any(re.search(pattern, name, re.I) for pattern in rules[kind]) + # Queue checks belong together even when their workflow name matches Nightly or Release. + if event == "merge_group": + return "merge_queue" if event == "workflow_call": - return "called" + return None # Workflow names determine the category independently of the branch line. if matches("release"): return "release" if matches("daily"): return "daily" if not matches("gate"): - return "other" + return None if event in PR_EVENTS: return "pr" if event == "workflow_dispatch": return "main" if event == "push": - return "main" if run.get("branch") == default_branch else "other" - if event is None: + return "main" if run.get("branch") == default_branch else None + if not event: # Degraded record without its trigger: only a linked PR is certain. - return "pr" if run.get("pull_requests") else "unknown" - # CI itself runs on PRs, pushes and dispatch; anything else came from a caller. - return "called" + return "pr" if run.get("pull_requests") else None + return None def pr_number(run, prs=()): @@ -126,7 +127,7 @@ def build_lanes(runs, *, default_branch, now, rules=None, prs=(), days=WINDOW_DA ``default_branch`` is the branch whose pushes form the "main" lane; a branch line passes its own branch and labels its push / manual lane with that ref. - Called and unclassified runs remain visible as distinct run IDs.""" + Runs outside the displayed lanes remain in history, without a fallback row.""" first = now.astimezone(zone).date() - timedelta(days=days - 1) axis = [first + timedelta(days=offset) for offset in range(days)] position = {day: index for index, day in enumerate(axis)} @@ -138,7 +139,6 @@ def build_lanes(runs, *, default_branch, now, rules=None, prs=(), days=WINDOW_DA if previous is None or (run.get("attempt") or 1) > (previous.get("attempt") or 1): latest[run.get("id")] = run cells = {key: [[] for _ in axis] for key, _ in lanes} - excluded = Counter() for run in latest.values(): created = parse(run.get("created_at")) if not created: @@ -151,7 +151,6 @@ def build_lanes(runs, *, default_branch, now, rules=None, prs=(), days=WINDOW_DA name = workflow_name(run, names) key = lane_of({**run, "name": name}, rules, default_branch) if key not in cells: - excluded[key] += 1 continue cells[key][day].append((created, run.get("id") or 0, { "id": run.get("id"), "attempt": run.get("attempt") or 1, "url": run.get("url"), "workflow": name, @@ -178,4 +177,4 @@ def build_lanes(runs, *, default_branch, now, rules=None, prs=(), days=WINDOW_DA since = since.astimezone(zone).date() if since else None return {"days": [day.isoformat() for day in axis], "window_days": days, "zone": zone.tzname(None), "order": "earliest_first", "collected_since": since.isoformat() if since and since > first else None, - "branch": default_branch, "lanes": rows, "excluded": dict(excluded)} + "branch": default_branch, "lanes": rows} diff --git a/gsb/incremental_project.py b/gsb/incremental_project.py index 85aaa04cd..06390a27a 100644 --- a/gsb/incremental_project.py +++ b/gsb/incremental_project.py @@ -187,7 +187,7 @@ def build_snapshot(sync): for line in lines: sections = old.get("sections", {}) if line["default"] else old.get("line_sections", {}).get(line["key"], {}) view = ((sections.get("ci") or {}).get("data") or {}).get("lanes") or {} - lanes_upgrade = lanes_upgrade or {lane["key"] for lane in view.get("lanes", [])} != {key for key, _ in LANES} + lanes_upgrade = lanes_upgrade or [lane["key"] for lane in view.get("lanes", [])] != [key for key, _ in LANES] # Reprojecting stored run categories must not invalidate test-artifact caches. if (not history.changed and not sync.tagged.changed and not cache_upgrade and not lanes_upgrade and not trend_upgrade and old.get("sync") == progress and old.get("generated_at", "")[:10] == stamp(midnight)[:10]): @@ -282,8 +282,8 @@ def coverage_owner(artifact): line_index = {ident: project_run(row) for ident, row in index.items() if run_line[ident] == key} ci = collect_ci(Context(_stored_ci(history, line_index), cfg, midnight, line_meta)) # Lanes read complete records (event, linked PRs, head repository) for the - # displayed window only, plus the PRs that were open during it. Every - # line keeps all run categories, including manually dispatched Nightly. + # displayed window only, plus the PRs that were open during it. Each + # line keeps the same five lanes, including manually dispatched Nightly. lane_runs = [project_run(history.get("runs", history_key(row)) or index[row["id"]]) for row in line_index.values() if (row.get("created_at") or "") >= start] ci["lanes"] = build_lanes(lane_runs, default_branch=line["ref"], now=sync.now, rules=sync.settings.get("workflows"), prs=[pr for pr in lane_prs if pr], diff --git a/site/app.js b/site/app.js index b7c7894f0..e591afa71 100644 --- a/site/app.js +++ b/site/app.js @@ -198,7 +198,7 @@ }; // CI history lanes: one row per trigger lane, one column per day on a shared axis. // The collector buckets days and orders runs, so a column simply stacks them top-down. - const LANE_SOURCE = { pr: 'CI · pull_request', main: 'CI · 默认分支 push / 手动', daily: 'Nightly · 定时 / 手动', release: 'Release · 版本运行', called: 'workflow_call / CI 调用事件', other: '其他工作流 / 分支事件', unknown: '缺少触发事件' }; + const LANE_SOURCE = { pr: 'CI · pull_request', main: 'CI · 默认分支 push / 手动', merge_queue: 'merge_group · 合并队列', daily: 'Nightly · 定时 / 手动', release: 'Release · 版本运行' }; const laneSource = (L, key) => (key === 'main' && L.branch ? `CI · ${L.branch} push / 手动` : LANE_SOURCE[key] || ''); const LANE_OUTCOMES = [['success', '成功'], ['failure', '失败/超时'], ['cancelled', '取消'], ['running', '运行中'], ['other', '其他(待批准等)']]; const LANE_EVENT = { schedule: 'schedule · 定时', workflow_dispatch: 'workflow_dispatch · 手动' }; @@ -223,7 +223,7 @@ const zone = L.zone || 'UTC+8'; const uncollected = (day) => L.collected_since && day < L.collected_since; const outcomes = (runs) => LANE_OUTCOMES.map(([key, name]) => [name, runs.filter((r) => r.outcome === key).length]).filter(([, count]) => count).map(([name, count]) => `${name} ${count}`).join(' · '); - const rows = L.lanes.map((lane) => { + const rows = Object.keys(LANE_SOURCE).map((key) => L.lanes.find((lane) => lane.key === key)).filter(Boolean).map((lane) => { const s = lane.summary; const counts = [['成功', s.success], ['失败', s.failure], ['取消', s.cancelled], ['运行中', s.running], ...(s.other ? [['其他', s.other]] : [])]; const head = `
${esc(lane.label).replace(/\//g, '/')}${esc(laneSource(L, lane.key))}` @@ -243,7 +243,7 @@ const notes = [ '成功率 = 成功 ÷(成功 + 失败/超时),取消、运行中与其他不计入;重跑按最后一次尝试着色,仍放在 run 创建的那天。', '耗时为最后一次尝试从开始到结束的时间;中位耗时只统计成功与失败的运行,运行中的没有耗时。', - '按 run ID 展示该分支线窗口内的全部已采集运行;workflow_call 子 run 单列在工作流调用层,其他工作流和未知触发事件也各自成点。', + '按 run ID 展示 PR、分支门禁、合并队列、Daily、版本五层;merge_group 优先归入合并队列。', L.collected_since ? `${laneDay(L.collected_since)} 之前的日期尚未采集(斜纹),不代表没有运行。` : '', ].filter(Boolean); const legendItems = LANE_OUTCOMES.map(([key, name]) => `${esc(name)}`).join('') + (L.collected_since ? '未采集' : ''); @@ -271,7 +271,7 @@ const lines = snap.lines || [], line = currentLine(snap); if (lines.length < 2) return ''; return `
分支线${lines.map((l) => ``).join('')}
` - + `当前 ${esc(line.ref)}:按运行分支及 PR 目标分支归类,包含 Daily 和其他工作流;各分支线的数据互不混合。
`; + + `当前 ${esc(line.ref)}:按运行分支及 PR 目标分支归类,按 PR、分支门禁、合并队列、Daily 与版本展示;各分支线的数据互不混合。
`; }; // The word for a line's own branch runs: 主干 on the default branch, the full branch name elsewhere. const trunkName = (line) => (line.default ? '主干' : ` ${line.ref} 分支`); @@ -400,7 +400,7 @@ const OUTCOME_TEXT = { success: ['成功', 'good'], failure: ['失败', 'bad'], cancelled: ['已取消', ''], running: ['运行中', 'warn'], other: ['待处理', 'warn'] }; // The newest `count` days of the lane chart, with each lane's summary recomputed for that window. const recentLanes = (L, count) => L && ({ ...L, days: L.days.slice(-count), collected_since: L.collected_since && L.collected_since > L.days[L.days.length - count] ? L.collected_since : null, - excluded: {}, lanes: L.lanes.map((lane) => { + lanes: L.lanes.map((lane) => { const days = lane.days.slice(-count), runs = days.flat(), by = (o) => runs.filter((r) => r.outcome === o).length; const success = by('success'), failure = by('failure'); const times = runs.filter((r) => (r.outcome === 'success' || r.outcome === 'failure') && r.duration_s != null).map((r) => r.duration_s).sort((a, b) => a - b); @@ -745,9 +745,12 @@ { key: 'last', label: '最近', render: (j) => (j.last ? `${conclusionBadge(j.last.conclusion)} ${link(j.last.url, ago(j.last.created_at))}` : '—'), sortable: false }, { key: 'steps', label: '常失败步骤', render: (j) => (j.top_failed_steps.length ? j.top_failed_steps.map((s) => `${esc(s.step)} ×${s.count}`).join(',') : '—'), sortable: false }, ], d.job_health, { emptyText: '没有可统计的 job' })}`; - const branches = [...new Set(d.recent_runs.map((r) => r.branch))]; + // Keep the run table within the displayed lanes, without a fallback list for hidden categories. + const visibleIds = new Set((L?.lanes || []).filter((lane) => Object.hasOwn(LANE_SOURCE, lane.key)).flatMap((lane) => lane.days.flatMap((runs) => runs.map((r) => r.id)))); + const recent = d.recent_runs.filter((r) => visibleIds.has(r.id)); + const branches = [...new Set(recent.map((r) => r.branch))]; const f = STATE.filters; - const runs = f.runBranch ? d.recent_runs.filter((r) => r.branch === f.runBranch) : d.recent_runs; + const runs = f.runBranch ? recent.filter((r) => r.branch === f.runBranch) : recent; html += sectionHead('最近 run', `${runs.length} 条`); html += `
${table('ci-runs', [ { key: 'conclusion', label: '结论', render: (r) => conclusionBadge(r.conclusion || r.status) }, diff --git a/site/index.html b/site/index.html index d53cb71b0..9233d0ad2 100644 --- a/site/index.html +++ b/site/index.html @@ -5,7 +5,7 @@ GitHub 状态看板 - +
@@ -44,8 +44,8 @@ - - - + + + diff --git a/static/app.js b/static/app.js index af34dd1a4..caccc0547 100644 --- a/static/app.js +++ b/static/app.js @@ -198,7 +198,7 @@ }; // CI history lanes: one row per trigger lane, one column per day on a shared axis. // The collector buckets days and orders runs, so a column simply stacks them top-down. - const LANE_SOURCE = { pr: 'CI · pull_request', main: 'CI · 默认分支 push / 手动', daily: 'Nightly · 定时 / 手动', release: 'Release · 版本运行', called: 'workflow_call / CI 调用事件', other: '其他工作流 / 分支事件', unknown: '缺少触发事件' }; + const LANE_SOURCE = { pr: 'CI · pull_request', main: 'CI · 默认分支 push / 手动', merge_queue: 'merge_group · 合并队列', daily: 'Nightly · 定时 / 手动', release: 'Release · 版本运行' }; const laneSource = (L, key) => (key === 'main' && L.branch ? `CI · ${L.branch} push / 手动` : LANE_SOURCE[key] || ''); const LANE_OUTCOMES = [['success', '成功'], ['failure', '失败/超时'], ['cancelled', '取消'], ['running', '运行中'], ['other', '其他(待批准等)']]; const LANE_EVENT = { schedule: 'schedule · 定时', workflow_dispatch: 'workflow_dispatch · 手动' }; @@ -223,7 +223,7 @@ const zone = L.zone || 'UTC+8'; const uncollected = (day) => L.collected_since && day < L.collected_since; const outcomes = (runs) => LANE_OUTCOMES.map(([key, name]) => [name, runs.filter((r) => r.outcome === key).length]).filter(([, count]) => count).map(([name, count]) => `${name} ${count}`).join(' · '); - const rows = L.lanes.map((lane) => { + const rows = Object.keys(LANE_SOURCE).map((key) => L.lanes.find((lane) => lane.key === key)).filter(Boolean).map((lane) => { const s = lane.summary; const counts = [['成功', s.success], ['失败', s.failure], ['取消', s.cancelled], ['运行中', s.running], ...(s.other ? [['其他', s.other]] : [])]; const head = `
${esc(lane.label).replace(/\//g, '/')}${esc(laneSource(L, lane.key))}` @@ -243,7 +243,7 @@ const notes = [ '成功率 = 成功 ÷(成功 + 失败/超时),取消、运行中与其他不计入;重跑按最后一次尝试着色,仍放在 run 创建的那天。', '耗时为最后一次尝试从开始到结束的时间;中位耗时只统计成功与失败的运行,运行中的没有耗时。', - '按 run ID 展示该分支线窗口内的全部已采集运行;workflow_call 子 run 单列在工作流调用层,其他工作流和未知触发事件也各自成点。', + '按 run ID 展示 PR、分支门禁、合并队列、Daily、版本五层;merge_group 优先归入合并队列。', L.collected_since ? `${laneDay(L.collected_since)} 之前的日期尚未采集(斜纹),不代表没有运行。` : '', ].filter(Boolean); const legendItems = LANE_OUTCOMES.map(([key, name]) => `${esc(name)}`).join('') + (L.collected_since ? '未采集' : ''); @@ -271,7 +271,7 @@ const lines = snap.lines || [], line = currentLine(snap); if (lines.length < 2) return ''; return `
分支线${lines.map((l) => ``).join('')}
` - + `当前 ${esc(line.ref)}:按运行分支及 PR 目标分支归类,包含 Daily 和其他工作流;各分支线的数据互不混合。
`; + + `当前 ${esc(line.ref)}:按运行分支及 PR 目标分支归类,按 PR、分支门禁、合并队列、Daily 与版本展示;各分支线的数据互不混合。
`; }; // The word for a line's own branch runs: 主干 on the default branch, the full branch name elsewhere. const trunkName = (line) => (line.default ? '主干' : ` ${line.ref} 分支`); @@ -400,7 +400,7 @@ const OUTCOME_TEXT = { success: ['成功', 'good'], failure: ['失败', 'bad'], cancelled: ['已取消', ''], running: ['运行中', 'warn'], other: ['待处理', 'warn'] }; // The newest `count` days of the lane chart, with each lane's summary recomputed for that window. const recentLanes = (L, count) => L && ({ ...L, days: L.days.slice(-count), collected_since: L.collected_since && L.collected_since > L.days[L.days.length - count] ? L.collected_since : null, - excluded: {}, lanes: L.lanes.map((lane) => { + lanes: L.lanes.map((lane) => { const days = lane.days.slice(-count), runs = days.flat(), by = (o) => runs.filter((r) => r.outcome === o).length; const success = by('success'), failure = by('failure'); const times = runs.filter((r) => (r.outcome === 'success' || r.outcome === 'failure') && r.duration_s != null).map((r) => r.duration_s).sort((a, b) => a - b); @@ -745,9 +745,12 @@ { key: 'last', label: '最近', render: (j) => (j.last ? `${conclusionBadge(j.last.conclusion)} ${link(j.last.url, ago(j.last.created_at))}` : '—'), sortable: false }, { key: 'steps', label: '常失败步骤', render: (j) => (j.top_failed_steps.length ? j.top_failed_steps.map((s) => `${esc(s.step)} ×${s.count}`).join(',') : '—'), sortable: false }, ], d.job_health, { emptyText: '没有可统计的 job' })}
`; - const branches = [...new Set(d.recent_runs.map((r) => r.branch))]; + // Keep the run table within the displayed lanes, without a fallback list for hidden categories. + const visibleIds = new Set((L?.lanes || []).filter((lane) => Object.hasOwn(LANE_SOURCE, lane.key)).flatMap((lane) => lane.days.flatMap((runs) => runs.map((r) => r.id)))); + const recent = d.recent_runs.filter((r) => visibleIds.has(r.id)); + const branches = [...new Set(recent.map((r) => r.branch))]; const f = STATE.filters; - const runs = f.runBranch ? d.recent_runs.filter((r) => r.branch === f.runBranch) : d.recent_runs; + const runs = f.runBranch ? recent.filter((r) => r.branch === f.runBranch) : recent; html += sectionHead('最近 run', `${runs.length} 条`); html += `
${table('ci-runs', [ { key: 'conclusion', label: '结论', render: (r) => conclusionBadge(r.conclusion || r.status) }, diff --git a/static/index.html b/static/index.html index d53cb71b0..9233d0ad2 100644 --- a/static/index.html +++ b/static/index.html @@ -5,7 +5,7 @@ GitHub 状态看板 - +
@@ -44,8 +44,8 @@ - - - + + + diff --git a/test/journey-board.spec.cjs b/test/journey-board.spec.cjs index bf3e2eaa9..eaa22c30e 100644 --- a/test/journey-board.spec.cjs +++ b/test/journey-board.spec.cjs @@ -16,7 +16,7 @@ test('all existing pages render; only static requests and no promotional copy', await expect(page.locator('#tab-overview .tile').filter({hasText:'PR 门禁'})).toContainText('耗时 18m 44s'); await expect(page.locator('#tab-overview .tile').filter({hasText:'主干门禁用例'})).toContainText('324'); await expect(page.locator('#tab-overview .tile').filter({hasText:'整仓行覆盖率'})).toContainText('80.7%'); - await expect(page.locator('#tab-overview .ci-lane-row:not(.ci-axis)')).toHaveCount(7); + await expect(page.locator('#tab-overview .ci-lane-row:not(.ci-axis)')).toHaveCount(5); await expect(page.locator('#tab-overview .ci-lane-row[data-lane="pr"] .ci-lane-day')).toHaveCount(14); await expect(page.locator('#tab-overview')).toContainText('v1.0'); await expect(page.locator('#meta')).toContainText('20:00'); @@ -409,10 +409,10 @@ test('CI trends, failed job steps, test distribution and coverage',async({page}) await expect(page.locator('#tab-ci')).toContainText('Coverage'); const lanes = page.locator('#tab-ci .ci-lanes'); const lane = (key) => lanes.locator(`.ci-lane-row[data-lane="${key}"]`); - await expect(lanes.locator('.ci-lane-row:not(.ci-axis)')).toHaveCount(7); - for (const key of ['pr','main','daily','release','called','other','unknown']) await expect(lane(key).locator('.ci-lane-day')).toHaveCount(30); + await expect(lanes.locator('.ci-lane-row:not(.ci-axis)')).toHaveCount(5); + for (const key of ['pr','main','merge_queue','daily','release']) await expect(lane(key).locator('.ci-lane-day')).toHaveCount(30); // One shared day axis: the newest column starts at the same x in every lane. - const newestX = await Promise.all(['pr','main','daily','release','called','other','unknown'].map(async (key) => Math.round((await lane(key).locator('.ci-lane-day').last().boundingBox()).x))); + const newestX = await Promise.all(['pr','main','merge_queue','daily','release'].map(async (key) => Math.round((await lane(key).locator('.ci-lane-day').last().boundingBox()).x))); expect(new Set(newestX).size).toBe(1); const today = lane('pr').locator('.ci-lane-day').last().locator('.ci-run'); await expect(today).toHaveCount(13); @@ -432,9 +432,18 @@ test('CI trends, failed job steps, test distribution and coverage',async({page}) await expect(lane('release').locator('.ci-run')).toHaveCount(0); await expect(lane('release')).toContainText('窗口内没有 run'); await expect(lane('release')).not.toContainText('失败 1'); - await expect(lane('called').locator('.ci-run')).toHaveAttribute('href', /\/runs\/399$/); - await expect(lane('other').locator('.ci-run')).toHaveAttribute('href', /\/runs\/398$/); - await expect(page.locator('#tab-ci .ci-lane-notes')).not.toContainText('已并入调用方'); + await expect(lanes.locator('.ci-lane-head b')).toHaveText(['PR', '主干', '合并队列', 'Daily', '版本']); + await expect(page.locator('#tab-ci')).not.toContainText(/工作流调用|其他工作流|未知触发/); + await expect(page.locator('#tab-ci a[href$="/runs/399"], #tab-ci a[href$="/runs/398"], #tab-ci a[href$="/runs/396"]')).toHaveCount(0); + const queue = lane('merge_queue').locator('.ci-run'); + await expect(queue).toHaveAttribute('href', /\/runs\/397$/); + await queue.focus(); + await expect(page.locator('#tooltip')).toContainText('CI · merge_group'); + await page.context().route('https://github.com/**/actions/runs/397', (route) => route.fulfill({ contentType: 'text/html', body: '

Queue run 397

' })); + const [openedRun] = await Promise.all([page.waitForEvent('popup'), queue.click()]); + await expect(openedRun).toHaveURL(/\/actions\/runs\/397$/); + await expect(openedRun.getByRole('heading')).toHaveText('Queue run 397'); + await openedRun.close(); await page.mouse.move(0, 0); await page.locator('#tab-ci .card:has(.ci-lanes)').screenshot({path:shot('ci-lanes-desktop')}); // Every finished run prints its time in its cell; a running one has none yet. @@ -445,7 +454,9 @@ test('CI trends, failed job steps, test distribution and coverage',async({page}) const scroller = page.locator('#tab-ci .ci-lanes-scroll'); // The day axis scrolls inside the card and opens on the newest day. await expect.poll(() => scroller.evaluate((el) => el.scrollWidth > el.clientWidth && el.scrollLeft + el.clientWidth >= el.scrollWidth - 2)).toBeTruthy(); - const heads = await Promise.all(['pr','main','daily','release','called','other','unknown'].map(async (key) => (await lane(key).locator('.ci-lane-head').boundingBox()))); + await expect(lanes.locator('.ci-lane-head b')).toHaveText(['PR', '主干', '合并队列', 'Daily', '版本']); + await expect(lanes).not.toContainText(/工作流调用|其他工作流|未知触发/); + const heads = await Promise.all(['pr','main','merge_queue','daily','release'].map(async (key) => (await lane(key).locator('.ci-lane-head').boundingBox()))); for (const [i, box] of heads.entries()) { expect(box.x).toBeGreaterThanOrEqual(0); if (i) expect(box.y).toBeGreaterThan(heads[i - 1].y + 20); @@ -1024,11 +1035,11 @@ test('CI, tests and coverage switch between main, legacy and release without mix const switchOf = (tab) => page.locator(`#tab-${tab} .line-switch`); await expect(switchOf('ci').locator('button')).toHaveText(['main', 'legacy', 'releases/v0.3.0.beta']); await expect(switchOf('ci').locator('button.on')).toHaveText('main'); - await expect(rows).toHaveCount(7); + await expect(rows).toHaveCount(5); await expect(ci.locator('.ci-lanes')).not.toContainText('legacy'); await expect(ci.locator('.ci-lanes .ci-run[href$="/runs/602"]')).toHaveCount(0); await switchOf('ci').getByRole('button', { name: 'legacy' }).click(); - await expect(rows).toHaveCount(7); + await expect(rows).toHaveCount(5); await expect(rows.nth(1).locator('.ci-lane-head b')).toHaveText('legacy'); await expect(rows.nth(1).locator('.ci-run')).toHaveAttribute('href', /\/runs\/610$/); await expect(rows.filter({has: page.locator('[href$="/runs/611"]')})).toHaveAttribute('data-lane', 'daily'); @@ -1038,8 +1049,8 @@ test('CI, tests and coverage switch between main, legacy and release without mix await expect(page.locator('#tab-tests')).not.toContainText('323 个用例'); await page.locator('[data-tab="ci"]').click(); await switchOf('ci').getByRole('button', { name: 'releases/v0.3.0.beta' }).click(); - // Nightly and reusable-workflow run IDs stay visible on this branch line. - await expect(rows).toHaveCount(7); + // Nightly stays in Daily; hidden categories have no rows or fallback run links. + await expect(rows).toHaveCount(5); await expect(rows.nth(1).locator('.ci-lane-head b')).toHaveText('releases/v0.3.0.beta'); await expect(rows.nth(1).locator('.ci-lane-head')).toContainText('CI · releases/v0.3.0.beta push / 手动'); await expect(rows.first().locator('.ci-run')).toHaveCount(2); @@ -1052,11 +1063,13 @@ test('CI, tests and coverage switch between main, legacy and release without mix await expect(nightly).toHaveAttribute('target', '_blank'); await nightly.focus(); await expect(page.locator('#tooltip')).toContainText('Nightly · workflow_dispatch · 手动'); - await expect(ci.locator('[data-lane="called"] .ci-run')).toHaveAttribute('href', /\/runs\/603$/); - await expect(ci.locator('[data-lane="other"] .ci-run')).toHaveAttribute('href', /\/runs\/604$/); - await expect(ci.locator('.ci-lane-notes')).not.toContainText('未画入'); + await expect(rows.locator('.ci-lane-head b')).toHaveText(['PR', 'releases/v0.3.0.beta', '合并队列', 'Daily', '版本']); + await expect(ci.locator('[data-lane="merge_queue"]')).toContainText('窗口内没有 run'); + await expect(ci.locator('[data-lane="merge_queue"]')).not.toContainText('失败 1'); + await expect(ci).not.toContainText(/工作流调用|其他工作流|未知触发/); + await expect(ci.locator('a[href$="/runs/603"], a[href$="/runs/604"]')).toHaveCount(0); const recent = ci.locator('th[data-table="ci-runs"]').first().locator('xpath=ancestor::table').locator('tbody tr'); - await expect(recent).toHaveCount(6); + await expect(recent).toHaveCount(4); await page.mouse.move(0, 0); await page.screenshot({ path: shot('line-ci-release-desktop') }); // The choice holds on the test and coverage pages and across a reload. @@ -1092,13 +1105,13 @@ test('CI, tests and coverage switch between main, legacy and release without mix expect(await branchHead.evaluate((el) => el.scrollWidth <= el.clientWidth + 1 && el.getBoundingClientRect().right <= el.closest('.ci-lane-head').getBoundingClientRect().right + 1)).toBeTruthy(); await page.locator('#tab-ci .card:has(.ci-lanes)').screenshot({ path: shot('line-ci-release-narrow') }); await switchOf('ci').getByRole('button', { name: 'main' }).click(); - await expect(rows).toHaveCount(7); + await expect(rows).toHaveCount(5); await page.locator('[data-tab="tests"]').click(); await expect(page.locator('#tab-tests')).toContainText('349 个用例'); // The overview stays on main and still surfaces the failing release run. await page.locator('[data-tab="overview"]').click(); await expect(page.locator('#tab-overview .health')).toContainText('releases/v0.3.0.beta 分支最近一次运行失败'); - await expect(page.locator('#tab-overview .ci-lane-row:not(.ci-axis)')).toHaveCount(7); + await expect(page.locator('#tab-overview .ci-lane-row:not(.ci-axis)')).toHaveCount(5); }); test('E2E navigation replaces tests and removed tabs have no dangling links', async ({ page }) => { diff --git a/test/prepare-site.py b/test/prepare-site.py index 7be7c2f59..7d74de6d9 100644 --- a/test/prepare-site.py +++ b/test/prepare-site.py @@ -149,7 +149,7 @@ def coverage_language(name,totals,group): rate=dict(success_rate=50,total=2,success=1,failure=1,cancelled=0,median_duration_s=120) ci=dict(default_branch='main',main=rate,pull_request=rate,red_streak_main=1,failures_7d=2,runs_sampled=3,job_history_runs=2,main_timeline=runs,latest_main=dict(run=runs[0],jobs=runs[0]['jobs']),workflows=[dict(name='CI',url=runs[0]['url'],path='.github/workflows/ci.yml',state='active',all=rate,main=rate,pull_request=rate,failures_7d=2,last_run=runs[0])],job_health=[dict(name='E2E',success_rate=50,runs=2,success=1,failure=1,cancelled=0,median_duration_s=120,last=runs[0],top_failed_steps=[dict(step='Run browser journeys',count=1)]),dict(name='Coverage',success_rate=100,runs=1,success=1,failure=0,cancelled=0,median_duration_s=385,last=runs[0],top_failed_steps=[])],recent_runs=runs) # CI lanes: 13 PR runs on the newest day (more than one column shows), manual and push -# runs on main, one nightly per evening, a Nightly-called CI child and no release. +# runs on main, one nightly per evening, a queue run, hidden categories and no release. from gsb.ci_lanes import LANES, build_lanes def lane_run(ident,name,event,created,branch,conclusion='success',pull_requests=()): # Finished runs take between 3 and 28 minutes; a running one has no time yet. @@ -161,9 +161,12 @@ def lane_run(ident,name,event,created,branch,conclusion='success',pull_requests= lane_runs+=[lane_run(400+d,'Nightly','schedule',f'2026-09-{d:02d}T18:00:00Z','main','failure' if d==16 else 'success') for d in range(12,20)] lane_runs.append(lane_run(399,'CI','workflow_call','2026-09-19T18:00:05Z','main')) lane_runs.append(lane_run(398,'Maintenance','push','2026-09-19T18:00:06Z','main')) +lane_runs.append(lane_run(397,'CI','merge_group','2026-09-20T05:00:00Z','gh-readonly-queue/main/pr-3')) +lane_runs.append(lane_run(396,'CI',None,'2026-09-20T05:01:00Z','main')) lanes=build_lanes(lane_runs,default_branch='main',now=datetime(2026,9,20,12,tzinfo=timezone.utc),rules=json.loads((root/'board-config.json').read_text())['workflows'],prs=[pr]) ci['lanes']=lanes -# Release branch line: PRs, a failing manual gate, Nightly and separate called/other runs. +ci['recent_runs']=lane_runs +# Release branch line: PRs, a failing manual gate, Nightly and hidden categories. swarm_runs=[lane_run(600,'CI','pull_request','2026-09-19T09:00:00Z','swarm-fix','success',pull_requests=[8]),lane_run(601,'CI','pull_request','2026-09-20T02:00:00Z','swarm-fix','failure',pull_requests=[8]), lane_run(602,'CI','workflow_dispatch','2026-09-20T03:00:00Z','releases/v0.3.0.beta','failure')] swarm_runs += [lane_run(37845266203,'Nightly','workflow_dispatch','2026-09-20T04:00:00Z','releases/v0.3.0.beta'), diff --git a/tests/test_ci_lanes.py b/tests/test_ci_lanes.py index 885bce378..1eead382b 100644 --- a/tests/test_ci_lanes.py +++ b/tests/test_ci_lanes.py @@ -38,16 +38,19 @@ class LaneClassificationTests(unittest.TestCase): def test_lanes_follow_how_actions_are_triggered(self): cases = [ ('CI', 'pull_request', 'feat', 'pr'), + ('CI', 'pull_request_target', 'feat', 'pr'), + ('Nightly', 'workflow_call', 'main', None), + ('Release', 'workflow_call', 'main', None), ('CI', 'push', 'main', 'main'), ('CI', 'workflow_dispatch', 'main', 'main'), - ('CI', 'push', 'master', 'other'), + ('CI', 'push', 'master', None), ('Nightly', 'schedule', 'main', 'daily'), ('Nightly', 'workflow_dispatch', 'main', 'daily'), ('Release', 'push', '0.3.0', 'release'), - ('CI', 'workflow_call', 'main', 'called'), + ('CI', 'workflow_call', 'main', None), # A CI run carrying its caller's trigger can only be a reusable call. - ('CI', 'schedule', 'main', 'called'), - ('.github/workflows/test-policy.yml', 'push', 'feat', 'other'), + ('CI', 'schedule', 'main', None), + ('.github/workflows/test-policy.yml', 'push', 'feat', None), ] for name, event, branch, expected in cases: with self.subTest(name=name, event=event): @@ -55,25 +58,25 @@ def test_lanes_follow_how_actions_are_triggered(self): def test_missing_trigger_degrades_without_guessing(self): self.assertEqual(lane_of(dict(name='CI', event=None, branch='feat', pull_requests=[5]), RULES, 'main'), 'pr') - self.assertEqual(lane_of(dict(name='CI', event=None, branch='main'), RULES, 'main'), 'unknown') - self.assertEqual(lane_of(dict(name='CI', branch='main'), None, 'main'), 'unknown') - - def test_all_run_ids_are_visible_including_called_other_and_unknown(self): - nightly = run(1, 'Nightly', 'schedule', '2026-09-21T18:00:00Z', 'main') - release = run(2, 'Release', 'push', '2026-09-21T09:00:00Z', '0.3.0') - children = [run(3, 'CI', 'workflow_call', '2026-09-21T18:00:05Z', 'main'), - run(4, 'CI', 'schedule', '2026-09-21T18:00:06Z', 'main'), - run(5, 'CI', 'push', '2026-09-21T09:00:04Z', '0.3.0')] - unknown = run(6, event=None, branch='main') - doc, by_key = lanes([nightly, release, *children, unknown, dict(children[0], attempt=2)]) - self.assertEqual(points(by_key['pr']) + points(by_key['main']), []) - self.assertEqual(points(by_key['daily']), [1]) - self.assertEqual(points(by_key['release']), [2]) - self.assertEqual(points(by_key['called']), [3, 4]) - self.assertEqual(points(by_key['other']), [5]) - self.assertEqual(points(by_key['unknown']), [6]) - self.assertEqual(doc['excluded'], {}) - self.assertEqual(sorted(ident for lane in by_key.values() for ident in points(lane)), list(range(1, 7))) + self.assertEqual(lane_of(dict(name='CI', event=None, branch='main'), RULES, 'main'), None) + self.assertEqual(lane_of(dict(name='CI', branch='main'), None, 'main'), None) + + def test_merge_group_precedes_every_workflow_name_rule(self): + runs = [run(i, name, 'merge_group') for i, name in enumerate(('CI', 'Nightly', 'Release', 'Maintenance'), 1)] + doc, by_key = lanes(runs) + self.assertEqual([(lane['key'], lane['label']) for lane in doc['lanes']], + [('pr', 'PR'), ('main', '主干'), ('merge_queue', '合并队列'), ('daily', 'Daily'), ('release', '版本')]) + self.assertEqual(points(by_key['merge_queue']), [1, 2, 3, 4]) + self.assertTrue(all(not points(lane) for key, lane in by_key.items() if key != 'merge_queue')) + + def test_hidden_categories_have_no_points_or_excluded_counts(self): + visible = [run(1, 'Nightly', 'schedule'), run(2, 'Release', 'push')] + hidden = [run(3, 'CI', 'workflow_call'), run(4, 'CI', 'schedule'), + run(5, 'CI', 'push', branch='other'), run(6, event=None), + run(7, 'Maintenance', 'push'), run(8, 'Nightly', 'workflow_call')] + doc, by_key = lanes([*visible, *hidden, dict(hidden[0], attempt=2)]) + self.assertEqual(sorted(ident for lane in by_key.values() for ident in points(lane)), [1, 2]) + self.assertNotIn('excluded', doc) def test_release_branch_keeps_manually_dispatched_nightly_in_daily(self): doc = build_lanes([run(37845266203, 'Nightly', 'workflow_dispatch', branch='releases/v0.3.0.beta')], @@ -81,7 +84,7 @@ def test_release_branch_keeps_manually_dispatched_nightly_in_daily(self): daily = next(lane for lane in doc['lanes'] if lane['key'] == 'daily') self.assertEqual(points(daily), [37845266203]) self.assertTrue(daily['days'][-1][0]['manual']) - self.assertEqual(doc['excluded'], {}) + self.assertNotIn('excluded', doc) def test_path_named_startup_failure_stays_with_its_workflow(self): # GitHub names a run after its file when the workflow does not parse. @@ -141,9 +144,9 @@ def test_rerun_uses_latest_attempt_on_creation_day(self): def test_lane_without_runs_is_empty_not_failed(self): _, by_key = lanes([run(1)]) - release = by_key['release'] - self.assertEqual(release['days'], [[] for _ in range(30)]) - self.assertEqual(release['summary'], dict(total=0, success=0, failure=0, cancelled=0, running=0, other=0, success_rate=None, median_duration_s=None)) + queue = by_key['merge_queue'] + self.assertEqual(queue['days'], [[] for _ in range(30)]) + self.assertEqual(queue['summary'], dict(total=0, success=0, failure=0, cancelled=0, running=0, other=0, success_rate=None, median_duration_s=None)) def test_summary_counts_each_outcome_and_completed_success_rate(self): results = [('success', 'completed'), ('failure', 'completed'), ('timed_out', 'completed'), ('startup_failure', 'completed'), @@ -232,7 +235,7 @@ def test_snapshot_lanes_use_history_records_even_with_old_index_shards(self): ci = self.snapshot(sync)['sections']['ci']['data'] lanes_by_key = {lane['key']: lane for lane in ci['lanes']['lanes']} self.assertEqual({k: points(v) for k, v in lanes_by_key.items()}, - {'pr': [1], 'main': [2, 5], 'daily': [3], 'release': [4], 'called': [], 'other': [], 'unknown': []}) + {'pr': [1], 'main': [2, 5], 'merge_queue': [], 'daily': [3], 'release': [4]}) self.assertEqual(lanes_by_key['pr']['days'][-1][0]['pr'], 7) self.assertEqual(ci['pull_request']['total'], 1) for name, content in sync.files().items(): diff --git a/tests/test_lines.py b/tests/test_lines.py index e26889920..f9170884f 100644 --- a/tests/test_lines.py +++ b/tests/test_lines.py @@ -95,11 +95,11 @@ def tests(ctx, runs, owns=None, coverage_eligible=None): self.assertEqual([(line["key"], line["default"]) for line in doc["lines"]], [("main", True), ("jiuwen", False)]) main, swarm = doc["sections"]["ci"]["data"], doc["line_sections"]["jiuwen"]["ci"]["data"] self.assertEqual({lane["key"]: points(lane) for lane in main["lanes"]["lanes"]}, - {"pr": [1], "main": [2, 5], "daily": [3], "release": [4], "called": [], "other": [], "unknown": []}) + {"pr": [1], "main": [2, 5], "merge_queue": [], "daily": [3], "release": [4]}) self.assertEqual({lane["key"]: points(lane) for lane in swarm["lanes"]["lanes"]}, - {"pr": [6, 7], "main": [8, 9], "daily": [], "release": [], "called": [], "other": [], "unknown": []}) + {"pr": [6, 7], "main": [8, 9], "merge_queue": [], "daily": [], "release": []}) self.assertEqual([lane["label"] for lane in swarm["lanes"]["lanes"]], - ["PR", "feat/jiuwenswarm", "Daily", "版本", "工作流调用", "其他工作流", "未知触发"]) + ["PR", "feat/jiuwenswarm", "合并队列", "Daily", "版本"]) self.assertEqual((swarm["default_branch"], swarm["lanes"]["branch"]), ("feat/jiuwenswarm", "feat/jiuwenswarm")) self.assertEqual((main["pull_request"]["total"], swarm["pull_request"]["total"], swarm["main"]["total"]), (1, 2, 2)) self.assertEqual(sorted(calls["main"][0]), [1, 2, 3, 4, 5]) @@ -131,13 +131,16 @@ def build(settings, collect): doc, built = build({"branch_lines": LINES}, False) self.assertEqual((sorted(doc["line_sections"]), built), (["jiuwen"], 2)) - def test_all_lines_keep_daily_called_and_other_runs_when_rebuilding_old_lanes(self): + def test_all_lines_rebuild_five_lanes_from_stored_runs_without_fetching_artifacts(self): source = self.source() source.runs += [ {**source.runs[2], "id": 37845266203, "event": "workflow_dispatch", "head_branch": "releases/v0.3.0.beta"}, {**source.runs[1], "id": 11, "event": "workflow_call", "head_branch": "releases/v0.3.0.beta"}, {**source.runs[1], "id": 12, "name": "Maintenance", "head_branch": "releases/v0.3.0.beta"}, {**source.runs[1], "id": 13, "name": "Maintenance", "head_branch": "feat/jiuwenswarm"}, + {**source.runs[1], "id": 14, "event": "merge_group", "head_branch": "feat/jiuwenswarm"}, + {**source.runs[1], "id": 15, "event": "merge_group", "head_branch": "releases/v0.3.0.beta"}, + {**source.runs[1], "id": 16, "event": None, "head_branch": "releases/v0.3.0.beta"}, ] settings = {"workflows": RULES, "branch_lines": RENAMED_LINES} sync = Sync(source, self.root, REPO, settings=settings, now=NOW).collect() @@ -145,32 +148,39 @@ def test_all_lines_keep_daily_called_and_other_runs_when_rebuilding_old_lanes(se doc = build_snapshot(sync) views = {"main": doc["sections"], **doc["line_sections"]} expected = { - "main": {"pr": [6, 7], "main": [8, 9], "release": [4], "other": [13]}, + "main": {"pr": [6, 7], "main": [8, 9], "release": [4], "merge_queue": [14]}, "legacy": {"pr": [1], "main": [2, 5], "daily": [3]}, - "release": {"daily": [37845266203], "called": [11], "other": [12]}, + "release": {"daily": [37845266203], "merge_queue": [15]}, } def check(snapshot): for key, sections in {"main": snapshot["sections"], **snapshot["line_sections"]}.items(): lanes = sections["ci"]["data"]["lanes"] self.assertEqual({lane["key"]: points(lane) for lane in lanes["lanes"] if points(lane)}, expected[key]) - self.assertEqual(lanes["excluded"], {}) + self.assertEqual([lane["key"] for lane in lanes["lanes"]], ["pr", "main", "merge_queue", "daily", "release"]) + self.assertNotIn("excluded", lanes) check(doc) - # A previously published two-lane branch view must be rebuilt even if - # the default line is current and no new records arrive in this poll. - for key in ("legacy", "release"): - lanes = views[key]["ci"]["data"]["lanes"] - lanes["lanes"] = [lane for lane in lanes["lanes"] if lane["key"] in ("pr", "main")] + # Rebuild the former seven-row snapshot even when no records change. + for sections in views.values(): + lanes = sections["ci"]["data"]["lanes"] + lanes["lanes"] = [lane for lane in lanes["lanes"] if lane["key"] != "merge_queue"] + lanes["lanes"] += [{"key": key, "summary": {"median_duration_s": None}} for key in ("called", "other", "unknown")] lanes["excluded"] = {"daily": 1} for name, content in {**sync.files(), "site/data/snapshot.json": json.dumps(doc)}.items(): path = self.root / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content) + source.calls.clear() sync = Sync(source, self.root, REPO, settings=settings, now=NOW) sync.meta = sync.gh.get("/repos/" + REPO) self.assertFalse(sync.history.changed) with patch("gsb.incremental_project.public_ops", return_value={}), patch("gsb.incremental_project.public_tests", return_value={}) as tests: check(build_snapshot(sync)) tests.assert_not_called() + # No polling, artifact listing or downloads are needed for this projection. + self.assertEqual(source.calls, [("/repos/" + REPO, {})]) + self.assertFalse(any("/records/" in name for name in sync.history.changed)) + for ident in (11, 12, 13, 16): + self.assertIn(ident, {row["id"] for _, row in sync.history.rows("runs")}) def test_renamed_branches_project_historical_runs_into_their_current_lines(self): source = self.source() @@ -190,7 +200,7 @@ def tests(ctx, runs, owns=None, coverage_eligible=None): {"pr": [6, 7], "main": [8, 9, 10], "release": [4]}) legacy = doc["line_sections"]["legacy"]["ci"]["data"] self.assertEqual({lane["key"]: points(lane) for lane in legacy["lanes"]["lanes"]}, - {"pr": [1], "main": [2, 5], "daily": [3], "release": [], "called": [], "other": [], "unknown": []}) + {"pr": [1], "main": [2, 5], "merge_queue": [], "daily": [3], "release": []}) self.assertEqual(sorted(calls["legacy"][0]), [1, 2, 3, 5]) self.assertEqual(sorted(calls["main"][0]), [4, 6, 7, 8, 9, 10]) old_main = {"run_id": 2, "branch": "main", "created_at": "2026-09-22T11:20:00Z"} From 60fbc4e3030984719db6a08ef744e8bef36a72fb Mon Sep 17 00:00:00 2001 From: zhaozhaozz <6450679+zhaozhaozz@users.noreply.github.com> Date: Tue, 20 Oct 2026 03:21:47 +0000 Subject: [PATCH 2/2] Preserve E2E fixture run IDs when adding CI lane examples --- test/prepare-site.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/prepare-site.py b/test/prepare-site.py index 7d74de6d9..d13d9937e 100644 --- a/test/prepare-site.py +++ b/test/prepare-site.py @@ -165,7 +165,8 @@ def lane_run(ident,name,event,created,branch,conclusion='success',pull_requests= lane_runs.append(lane_run(396,'CI',None,'2026-09-20T05:01:00Z','main')) lanes=build_lanes(lane_runs,default_branch='main',now=datetime(2026,9,20,12,tzinfo=timezone.utc),rules=json.loads((root/'board-config.json').read_text())['workflows'],prs=[pr]) ci['lanes']=lanes -ci['recent_runs']=lane_runs +# E2E histories also use these run IDs to identify their branch line. +ci['recent_runs']=[*runs,*lane_runs] # Release branch line: PRs, a failing manual gate, Nightly and hidden categories. swarm_runs=[lane_run(600,'CI','pull_request','2026-09-19T09:00:00Z','swarm-fix','success',pull_requests=[8]),lane_run(601,'CI','pull_request','2026-09-20T02:00:00Z','swarm-fix','failure',pull_requests=[8]), lane_run(602,'CI','workflow_dispatch','2026-09-20T03:00:00Z','releases/v0.3.0.beta','failure')]