Skip to content

Commit fe48221

Browse files
Merge remote-tracking branch 'origin/main' into fix/issue-26749
2 parents 2facba4 + 5eebf77 commit fe48221

43 files changed

Lines changed: 1009 additions & 217 deletions

Some content is hidden

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

‎.github/workflows/mobile-fingerprint-check.yml‎

Lines changed: 15 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ name: Mobile Fingerprint Check
22

33
# Detects whether a PR changes the native fingerprint — i.e. whether merging
44
# it would leave main un-OTA-able until a new store build ships. Native-change
5-
# PRs get the "📱 Native Change" label so they can be held and merged as a
5+
# PRs get the "📱 Native Change" label (applied by mobile-fingerprint-label.yml,
6+
# which runs from main so fork PRs get it too) so they can be held and merged as a
67
# batch right before the next store submission, keeping main OTA-able for
78
# everything else in between. (Once one native PR merges, every later merge
89
# inherits the drifted fingerprint and loses OTA reach too — that is why the
@@ -27,8 +28,6 @@ jobs:
2728
runs-on: blacksmith-8vcpu-ubuntu-2404
2829
permissions:
2930
contents: read
30-
issues: write
31-
pull-requests: write
3231
env:
3332
APP_VARIANT: production
3433
NODE_OPTIONS: --max-old-space-size=8192
@@ -127,94 +126,19 @@ jobs:
127126
} >> "$GITHUB_STEP_SUMMARY"
128127
echo "changed_platforms=${changed# }" >> "$GITHUB_OUTPUT"
129128
130-
- name: Sync native change label
131-
# Fork PRs get a read-only token under pull_request; the check stays
132-
# advisory there (summary only). This workflow must not move to
133-
# pull_request_target — it installs and runs PR code.
134-
if: github.event.pull_request.head.repo.full_name == github.repository
135-
uses: actions/github-script@v8
129+
# mobile-fingerprint-label.yml reads this from main with a write token
130+
# and syncs the label; this job's token is read-only for fork PRs.
131+
- name: Record changed platforms
136132
env:
137133
CHANGED_PLATFORMS: ${{ steps.compare.outputs.changed_platforms }}
138-
with:
139-
script: |
140-
const managedLabel = {
141-
name: "📱 Native Change",
142-
color: "d93f0b",
143-
description:
144-
"Changes the native fingerprint; merging blocks production OTAs until a new store build ships.",
145-
};
146-
const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== "";
147-
const issueNumber = context.payload.pull_request.number;
148-
149-
try {
150-
const { data: existing } = await github.rest.issues.getLabel({
151-
owner: context.repo.owner,
152-
repo: context.repo.repo,
153-
name: managedLabel.name,
154-
});
155-
156-
if (
157-
existing.color !== managedLabel.color ||
158-
(existing.description ?? "") !== managedLabel.description
159-
) {
160-
await github.rest.issues.updateLabel({
161-
owner: context.repo.owner,
162-
repo: context.repo.repo,
163-
name: managedLabel.name,
164-
color: managedLabel.color,
165-
description: managedLabel.description,
166-
});
167-
}
168-
} catch (error) {
169-
if (error.status !== 404) {
170-
throw error;
171-
}
172-
173-
try {
174-
await github.rest.issues.createLabel({
175-
owner: context.repo.owner,
176-
repo: context.repo.repo,
177-
name: managedLabel.name,
178-
color: managedLabel.color,
179-
description: managedLabel.description,
180-
});
181-
} catch (createError) {
182-
if (createError.status !== 422) {
183-
throw createError;
184-
}
185-
}
186-
}
187-
188-
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
189-
owner: context.repo.owner,
190-
repo: context.repo.repo,
191-
issue_number: issueNumber,
192-
per_page: 100,
193-
});
194-
const hasLabel = currentLabels.some((label) => label.name === managedLabel.name);
195-
196-
if (nativeChanged && !hasLabel) {
197-
await github.rest.issues.addLabels({
198-
owner: context.repo.owner,
199-
repo: context.repo.repo,
200-
issue_number: issueNumber,
201-
labels: [managedLabel.name],
202-
});
203-
} else if (!nativeChanged && hasLabel) {
204-
try {
205-
await github.rest.issues.removeLabel({
206-
owner: context.repo.owner,
207-
repo: context.repo.repo,
208-
issue_number: issueNumber,
209-
name: managedLabel.name,
210-
});
211-
} catch (removeError) {
212-
if (removeError.status !== 404) {
213-
throw removeError;
214-
}
215-
}
216-
}
134+
run: |
135+
mkdir -p "$RUNNER_TEMP/fingerprint-result"
136+
printf '%s\n' "$CHANGED_PLATFORMS" > "$RUNNER_TEMP/fingerprint-result/changed-platforms"
217137
218-
core.info(
219-
`PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`,
220-
);
138+
- name: Upload changed platforms
139+
uses: actions/upload-artifact@v7
140+
with:
141+
name: fingerprint-result
142+
path: ${{ runner.temp }}/fingerprint-result
143+
if-no-files-found: error
144+
retention-days: 1
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
name: Mobile Fingerprint Label
2+
3+
# Trusted half of mobile-fingerprint-check.yml. That workflow runs PR code
4+
# (fork PRs included) with a read-only token and records which platforms'
5+
# fingerprints changed. This one runs from main with a write token and syncs
6+
# the "📱 Native Change" label. It never checks out or runs PR code.
7+
#
8+
# A PR can edit the check workflow it runs under, so the recorded result is
9+
# untrusted data: it is validated, and at worst a PR mislabels itself.
10+
11+
on:
12+
workflow_run:
13+
workflows: [Mobile Fingerprint Check]
14+
types: [completed]
15+
16+
permissions:
17+
contents: read
18+
19+
jobs:
20+
label:
21+
name: Sync native change label
22+
# A cancelled run was superseded by a newer push, whose run labels instead.
23+
if: >-
24+
github.event.workflow_run.event == 'pull_request' &&
25+
github.event.workflow_run.conclusion == 'success'
26+
runs-on: ubuntu-24.04
27+
timeout-minutes: 5
28+
permissions:
29+
actions: read
30+
contents: read
31+
issues: write
32+
pull-requests: write
33+
steps:
34+
- id: pr
35+
name: Resolve the pull request behind the check
36+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
37+
with:
38+
script: |
39+
const run = context.payload.workflow_run;
40+
const { owner, repo } = context.repo;
41+
42+
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
43+
owner,
44+
repo,
45+
run_id: run.id,
46+
per_page: 100,
47+
});
48+
const results = artifacts.filter(
49+
(artifact) => artifact.name === "fingerprint-result" && !artifact.expired,
50+
);
51+
if (results.length !== 1) {
52+
core.info(`Expected one fingerprint-result artifact; found ${results.length}. Skipping.`);
53+
return;
54+
}
55+
56+
// workflow_run.pull_requests is empty for fork PRs, so resolve the
57+
// PR from the checked commit. Requiring the PR's current head to be
58+
// that commit keeps a slow run for an older push from overwriting a
59+
// newer result.
60+
const associated = await github.paginate(
61+
github.rest.repos.listPullRequestsAssociatedWithCommit,
62+
{ owner, repo, commit_sha: run.head_sha, per_page: 100 },
63+
);
64+
const matching = associated.filter(
65+
(candidate) =>
66+
candidate.state === "open" &&
67+
candidate.head.sha === run.head_sha &&
68+
candidate.head.ref === run.head_branch &&
69+
candidate.head.repo?.full_name === run.head_repository?.full_name,
70+
);
71+
if (matching.length !== 1) {
72+
core.info(`Expected one open PR at ${run.head_sha}; found ${matching.length}. Skipping.`);
73+
return;
74+
}
75+
76+
core.setOutput("artifact_id", String(results[0].id));
77+
core.setOutput("pr_number", String(matching[0].number));
78+
79+
# Read the archive as bytes; never extract PR-produced files into a
80+
# workspace this job runs code from.
81+
- id: result
82+
name: Read changed platforms
83+
if: steps.pr.outputs.pr_number != ''
84+
shell: bash
85+
env:
86+
GH_TOKEN: ${{ github.token }}
87+
ARTIFACT_ID: ${{ steps.pr.outputs.artifact_id }}
88+
run: |
89+
set -euo pipefail
90+
gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${ARTIFACT_ID}/zip" > "$RUNNER_TEMP/fingerprint-result.zip"
91+
changed="$(unzip -p "$RUNNER_TEMP/fingerprint-result.zip" changed-platforms)"
92+
if [[ ! "$changed" =~ ^((ios|android)( (ios|android))?)?$ ]]; then
93+
echo "Unexpected changed-platforms value '$changed'." >&2
94+
exit 1
95+
fi
96+
echo "changed_platforms=$changed" >> "$GITHUB_OUTPUT"
97+
98+
- name: Sync native change label
99+
if: steps.pr.outputs.pr_number != ''
100+
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
101+
env:
102+
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
103+
CHANGED_PLATFORMS: ${{ steps.result.outputs.changed_platforms }}
104+
with:
105+
script: |
106+
const managedLabel = {
107+
name: "📱 Native Change",
108+
color: "d93f0b",
109+
description:
110+
"Changes the native fingerprint; merging blocks production OTAs until a new store build ships.",
111+
};
112+
const nativeChanged = (process.env.CHANGED_PLATFORMS ?? "").trim() !== "";
113+
const issueNumber = Number(process.env.PR_NUMBER);
114+
115+
try {
116+
const { data: existing } = await github.rest.issues.getLabel({
117+
owner: context.repo.owner,
118+
repo: context.repo.repo,
119+
name: managedLabel.name,
120+
});
121+
122+
if (
123+
existing.color !== managedLabel.color ||
124+
(existing.description ?? "") !== managedLabel.description
125+
) {
126+
await github.rest.issues.updateLabel({
127+
owner: context.repo.owner,
128+
repo: context.repo.repo,
129+
name: managedLabel.name,
130+
color: managedLabel.color,
131+
description: managedLabel.description,
132+
});
133+
}
134+
} catch (error) {
135+
if (error.status !== 404) {
136+
throw error;
137+
}
138+
139+
try {
140+
await github.rest.issues.createLabel({
141+
owner: context.repo.owner,
142+
repo: context.repo.repo,
143+
name: managedLabel.name,
144+
color: managedLabel.color,
145+
description: managedLabel.description,
146+
});
147+
} catch (createError) {
148+
if (createError.status !== 422) {
149+
throw createError;
150+
}
151+
}
152+
}
153+
154+
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
155+
owner: context.repo.owner,
156+
repo: context.repo.repo,
157+
issue_number: issueNumber,
158+
per_page: 100,
159+
});
160+
const hasLabel = currentLabels.some((label) => label.name === managedLabel.name);
161+
162+
if (nativeChanged && !hasLabel) {
163+
await github.rest.issues.addLabels({
164+
owner: context.repo.owner,
165+
repo: context.repo.repo,
166+
issue_number: issueNumber,
167+
labels: [managedLabel.name],
168+
});
169+
} else if (!nativeChanged && hasLabel) {
170+
try {
171+
await github.rest.issues.removeLabel({
172+
owner: context.repo.owner,
173+
repo: context.repo.repo,
174+
issue_number: issueNumber,
175+
name: managedLabel.name,
176+
});
177+
} catch (removeError) {
178+
if (removeError.status !== 404) {
179+
throw removeError;
180+
}
181+
}
182+
}
183+
184+
core.info(
185+
`PR #${issueNumber}: native fingerprint ${nativeChanged ? `changed (${process.env.CHANGED_PLATFORMS})` : "unchanged"}`,
186+
);

‎apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
pullRequestDiffInitialPosition,
4242
} from "./pullRequestDiffDebug";
4343
import { usePullRequestDiffHighlightFiles } from "./usePullRequestDiffHighlightFiles";
44-
import { isPullRequestTestFile } from "./pullRequestFileFilters";
44+
import { isTestChangedFile } from "@t3tools/shared/changedFileKind";
4545
import { useFocusEffect, useNavigation, type StaticScreenProps } from "@react-navigation/native";
4646
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
4747
import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react";
@@ -1035,7 +1035,7 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) {
10351035
const jumpedUnder = useRef<boolean | null>(filter.hideTestFiles);
10361036
const hasTestFiles =
10371037
result.parsed.kind === "files" &&
1038-
result.parsed.files.some((file) => isPullRequestTestFile(file.path));
1038+
result.parsed.files.some((file) => isTestChangedFile(file.path));
10391039
const jumpStarted = () =>
10401040
jumpedUnder.current !== null && (!hasTestFiles || jumpedUnder.current === filter.hideTestFiles);
10411041
const scrollToFile = (fileId: string) => {

‎apps/mobile/src/features/pull-requests/pullRequestFileFilters.ts‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
1-
import { classifyChangedFile } from "@t3tools/shared/changedFileKind";
1+
import { isTestChangedFile } from "@t3tools/shared/changedFileKind";
22
import type { ReviewParsedDiff } from "../review/reviewModel";
33

4-
/** Test support such as fixtures and snapshots is hidden along with the tests. */
5-
export function isPullRequestTestFile(path: string): boolean {
6-
const kind = classifyChangedFile(path);
7-
return kind === "tests" || kind === "mocks";
8-
}
9-
104
export function filterPullRequestTestFiles(
115
parsed: ReviewParsedDiff,
126
hideTestFiles: boolean,
137
includePath?: string,
148
): ReviewParsedDiff {
159
if (!hideTestFiles || parsed.kind !== "files") return parsed;
1610
const files = parsed.files.filter(
17-
(file) => file.path === includePath || !isPullRequestTestFile(file.path),
11+
(file) => file.path === includePath || !isTestChangedFile(file.path),
1812
);
1913
if (files.length === parsed.files.length) return parsed;
2014
return {

‎apps/mobile/src/features/threads/pending-thread-feed.test.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,14 +114,17 @@ describe("pending timeline messages", () => {
114114
expect(appendPendingThreadMessages([], [delivered], [queued])).toEqual([]);
115115
});
116116

117-
it("stops showing an acknowledged message once the projection holds it, even when the feed hides it", () => {
117+
it("stops showing an acknowledged message once the thread catches up, even when the feed hides it", () => {
118118
// A rolled-back or cancelled queued run keeps its message in projection.messages
119119
// while its user_message item leaves the visible feed.
120120
const echoed = pending("rolled-back");
121-
const waiting = pending("waiting");
122-
const shown = selectPendingAcknowledgedMessages([echoed, waiting], {
121+
// A bounded projection pages an applied message out; it must not come back as pending.
122+
const pagedOut = { ...pending("paged-out"), sequence: 40 };
123+
const waiting = { ...pending("waiting"), sequence: 50 };
124+
const shown = selectPendingAcknowledgedMessages([echoed, pagedOut, waiting], {
123125
threadKey: "env:thread",
124126
queuedMessages: [],
127+
sequence: 45,
125128
projectedMessages: [{ id: echoed.messageId }],
126129
});
127130
expect(shown).toEqual([waiting]);

0 commit comments

Comments
 (0)