Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 78 additions & 9 deletions .github/workflows/expo-code-review-command.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,34 @@ name: AI code review (command)
# This never changes configuration. CONTINUOUS review is configured in
# expo-code-review.yml (the `pull_request` workflow) via the `review.trigger`
# policy in .expo-code-review/config.jsonc and the `ai-review:skip` label.
#
# Also dispatchable manually (Actions → this workflow → Run workflow) with a PR
# number. Dispatch requires write access, so it carries the same maintainers-only
# gate as the comment path. The `config-from-checkout` input exists for PRs whose
# base commit predates .expo-code-review/ — `ecr ci` loads configuration from the
# PR's trusted base commit and fails closed when that commit has no config, so
# such PRs can only be reviewed by trusting this base-ref checkout's config
# (still never the PR head).

on:
issue_comment:
types: [created]
workflow_dispatch:
inputs:
pr:
description: PR number to review
type: number
required: true
agents:
description: "Agent ids (comma-separated), 'all' for every agent, or empty to let the router pick"
type: string
required: false
default: ''
config-from-checkout:
description: Load reviewer config from this checkout instead of the PR's base commit (for PRs whose base predates .expo-code-review/)
type: boolean
required: false
default: false

# Comment-only: read the repo, write PR comments (issue comments API).
permissions:
Expand All @@ -25,11 +49,12 @@ permissions:

jobs:
command:
# Only PR comments starting with /review, /expo-review, @expo-bot review,
# or @expo-bot check.
# Manual dispatch (write access required), or PR comments starting with
# /review, /expo-review, @expo-bot review, or @expo-bot check.
# @ref LLP 0009#workflow-security-posture [implements] — gate controls who triggers, not what code runs
if: >-
github.event.issue.pull_request != null &&
github.event_name == 'workflow_dispatch' ||
(github.event.issue.pull_request != null &&
github.event.comment.user.login != 'expo-bot' &&
(github.event.comment.body == '/review' ||
startsWith(github.event.comment.body, '/review ') ||
Expand All @@ -39,12 +64,12 @@ jobs:
startsWith(github.event.comment.body, '@expo-bot review ') ||
github.event.comment.body == '@expo-bot check' ||
startsWith(github.event.comment.body, '@expo-bot check ')) &&
contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
contains(fromJson('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association))
runs-on: ubuntu-latest
# Job-level so a prose comment that the gate rejects cannot claim this
# group at workflow creation and cancel a real review already in flight.
concurrency:
group: ai-code-review-cmd-${{ github.event.issue.number }}
group: ai-code-review-cmd-${{ github.event.issue.number || inputs.pr }}
cancel-in-progress: true
# Bound the run so a slow/stalled review fails fast rather than hanging. Keep it
# above the passes budget (budget.totalPassesMinutes, 55m) + coordinator (10m) +
Expand All @@ -59,7 +84,26 @@ jobs:
env:
# Via env (never inline ${{ }}) so an untrusted comment can't inject shell.
COMMENT: ${{ github.event.comment.body }}
DISPATCH: ${{ github.event_name == 'workflow_dispatch' }}
DISPATCH_AGENTS: ${{ inputs.agents }}
run: |
# Manual dispatch: agent selection comes from inputs, with the same
# sanitization as the comment path below.
if [ "$DISPATCH" = "true" ]; then
agents=""
route=false
if [ -z "$DISPATCH_AGENTS" ]; then
route=true
elif [ "$DISPATCH_AGENTS" != "all" ]; then
agents=$(printf '%s' "$DISPATCH_AGENTS" | tr ' ' ',' | tr -cd 'a-zA-Z0-9,_-')
fi
{
echo "run=true"
echo "agents=$agents"
echo "route=$route"
} >> "$GITHUB_OUTPUT"
exit 0
fi
line=$(printf '%s' "$COMMENT" | head -n1 | tr -d '\r')
first=$(printf '%s' "$line" | awk '{print tolower($1)}')
second=$(printf '%s' "$line" | awk '{print tolower($2)}')
Expand Down Expand Up @@ -102,7 +146,8 @@ jobs:
} >> "$GITHUB_OUTPUT"

- name: Acknowledge
if: steps.cmd.outputs.run == 'true'
# Comment path only — a manual dispatch has no comment to react to.
if: steps.cmd.outputs.run == 'true' && github.event_name == 'issue_comment'
env:
GH_TOKEN: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
run: gh api -X POST "repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions" -f content=eyes
Expand Down Expand Up @@ -182,7 +227,7 @@ jobs:
if: always() && steps.cmd.outputs.run == 'true' && steps.model-env.outcome == 'failure'
env:
GH_TOKEN: ${{ secrets.EXPO_BOT_GITHUB_TOKEN }}
PR: ${{ github.event.issue.number }}
PR: ${{ github.event.issue.number || inputs.pr }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
# See the matching step in expo-code-review.yml: pick.sh writes this
Expand Down Expand Up @@ -214,6 +259,9 @@ jobs:
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
AGENTS: ${{ steps.cmd.outputs.agents }}
ROUTE: ${{ steps.cmd.outputs.route }}
DISPATCH: ${{ github.event_name == 'workflow_dispatch' }}
PR_NUMBER: ${{ inputs.pr }}
CONFIG_FROM_CHECKOUT: ${{ inputs.config-from-checkout }}
# NOTE: running via `issue_comment` makes this a manual review command, which
# the CLI detects (GITHUB_EVENT_NAME=issue_comment) and treats as a trigger-gate bypass
# — it reviews even when the config trigger policy or an `ai-review:skip` label
Expand All @@ -229,18 +277,39 @@ jobs:
elif [ "$ROUTE" = "true" ]; then
ARGS=(--route)
fi
if [ "$DISPATCH" = "true" ]; then
# A dispatch's event payload has no PR number for the CLI to read, and
# the runner forbids overriding GITHUB_* defaults via step `env:` — so
# export the CLI's documented GITHUB_REF fallback from inside the
# script, where the runner can't overwrite it.
case "$PR_NUMBER" in
''|*[!0-9]*) echo "invalid pr input: $PR_NUMBER" >&2; exit 1 ;;
esac
export GITHUB_REF="refs/pull/${PR_NUMBER}/merge"
# A dispatch is a manual command like /review, but the CLI only
# infers the trigger-gate bypass from GITHUB_EVENT_NAME=issue_comment,
# so pass --force explicitly.
ARGS+=(--force)
if [ "$CONFIG_FROM_CHECKOUT" = "true" ]; then
# Operator trust decision (absolute path): PRs whose base commit
# predates .expo-code-review/ have no trusted-base config to load,
# so trust this base-ref checkout's config — still never the PR head.
ARGS+=(--config-dir "$GITHUB_WORKSPACE/.expo-code-review")
fi
fi
./scripts/expo-code-review ecr ci "${ARGS[@]}"

# Same ephemeral per-run log as the pull_request workflow — a review command
# runs the full `ecr ci`, whose .expo-code-review/.runs/ log is gone when the
# runner tears down. always() captures it even on error, gated on run=='true'
# (an unrelated comment writes no log); issue.number IS the PR number here
# (issue_comment context has no pull_request.number).
# (issue_comment context has no pull_request.number), and inputs.pr covers
# the manual-dispatch path.
- name: Upload review run log
if: always() && steps.cmd.outputs.run == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: review-run-log-pr${{ github.event.issue.number }}
name: review-run-log-pr${{ github.event.issue.number || inputs.pr }}
path: .expo-code-review/.runs/reviews.jsonl
if-no-files-found: ignore
retention-days: 14
9 changes: 9 additions & 0 deletions apps/expo-go/ios/Client/SwiftUI/GraphQL/Errors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ enum APIError: LocalizedError {
}
}

// Cancellation happens whenever SwiftUI tears down a refreshable or task modifier; it is
// never worth surfacing to the user.
var isCancellation: Bool {
if case .networkError(let error) = self {
return error is CancellationError || (error as? URLError)?.code == .cancelled
}
return false
}

var isAuthenticationError: Bool {
if case .httpError(let statusCode, _) = self, statusCode == 401 {
return true
Expand Down
1 change: 1 addition & 0 deletions apps/expo-go/ios/Client/SwiftUI/HomeRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ struct HomeRootView: View {
self.viewModel = viewModel
let shouldSkip = DevelopmentServerService.isSimulator
|| UserDefaults.standard.bool(forKey: DevelopmentServerService.networkPermissionGrantedKey)
|| !UserDefaults.standard.bool(forKey: "ExpoGoOnboardingFinished")
_hasCompletedPermissionFlow = State(initialValue: shouldSkip)
}

Expand Down
2 changes: 2 additions & 0 deletions apps/expo-go/ios/Client/SwiftUI/HomeTabView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ struct HomeTabView: View {

UpgradeWarningView()

NetworkPermissionBanner(serverService: viewModel.serverService)

DevServersSection()

if !viewModel.recentlyOpenedApps.isEmpty {
Expand Down
11 changes: 8 additions & 3 deletions apps/expo-go/ios/Client/SwiftUI/HomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,14 @@ class HomeViewModel: ObservableObject {
func addToRecentlyOpened(url: String, name: String, iconUrl: String? = nil) {
let normalizedUrl = normalizeUrl(url)

if let existingIndex = recentlyOpenedApps.firstIndex(where: {
// Update permalinks are unique per published update, so entries for the same app are
// matched by name instead of URL to avoid one row per update.
let isDuplicate: (RecentlyOpenedApp) -> Bool = {
normalizeUrl($0.url) == normalizedUrl
}) {
|| (isUpdatePermalink($0.url) && isUpdatePermalink(url) && $0.name == name)
}

if let existingIndex = recentlyOpenedApps.firstIndex(where: isDuplicate) {
let existingApp = recentlyOpenedApps[existingIndex]

if existingApp.name == name && iconUrl != nil && existingApp.iconUrl == nil {
Expand All @@ -160,7 +165,7 @@ class HomeViewModel: ObservableObject {
return
}

recentlyOpenedApps.remove(at: existingIndex)
recentlyOpenedApps.removeAll(where: isDuplicate)
}

let newApp = RecentlyOpenedApp(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ class DevelopmentServerService: ObservableObject {
private var sessionSecret: String?
private var remoteRefreshTask: Task<Void, Never>?
private var browser: NWBrowser?
private var probeListener: NWListener?
private let probeServiceName = "expo-go-permission-probe"
private var pingTask: Task<Void, Never>?
private var isFetchingRemote = false

Expand Down Expand Up @@ -59,7 +61,20 @@ class DevelopmentServerService: ObservableObject {
permissionStatus = .granted
}

func markNetworkPermissionDenied() {
UserDefaults.standard.set(false, forKey: Self.networkPermissionGrantedKey)
permissionStatus = .denied
}

func checkLocalNetworkAccess() async -> Bool {
let granted = await probeLocalNetworkAccess()
if granted {
markNetworkPermissionGranted()
}
return granted
}

private func probeLocalNetworkAccess() async -> Bool {
let serviceType = bonjourType
let queue = DispatchQueue(label: "expo.go.permissioncheck")

Expand Down Expand Up @@ -326,11 +341,11 @@ class DevelopmentServerService: ObservableObject {
switch state {
case .waiting(let error):
if case .dns(let dnsError) = error, dnsError == kDNSServiceErr_PolicyDenied {
self.permissionStatus = .denied
self.markNetworkPermissionDenied()
}
case .failed(let error):
if case .dns(let dnsError) = error, dnsError == kDNSServiceErr_PolicyDenied {
self.permissionStatus = .denied
self.markNetworkPermissionDenied()
}
default:
break
Expand All @@ -342,28 +357,48 @@ class DevelopmentServerService: ObservableObject {
guard let self else { return }
Task { @MainActor [weak self, results] in
guard let self else { return }
// Results only flow once the permission is truly granted, so this is the reliable signal.
self.markNetworkPermissionGranted()
self.probeListener?.cancel()
self.probeListener = nil
self.pingTask?.cancel()
self.pingTask = Task {
defer { self.pingTask = nil }
await self.pingDiscoveryResults(results.map { result in
DiscoveryResult(
name: NetworkUtilities.getNWBrowserResultName(result),
endpoint: result.endpoint
)
await self.pingDiscoveryResults(results.compactMap { result in
let name = NetworkUtilities.getNWBrowserResultName(result)
if name?.hasPrefix(self.probeServiceName) == true {
return nil
}
return DiscoveryResult(name: name, endpoint: result.endpoint)
})
}
}
}

startProbeListener()
browser?.start(queue: DispatchQueue(label: "expo.go.bonjour.discovery"))
}

// Advertise our own service so a granted permission always produces at least one browse
// result, even when no dev servers are running.
private func startProbeListener() {
guard let listener = try? NWListener(using: .tcp, on: .any) else {
return
}
listener.service = NWListener.Service(name: probeServiceName, type: bonjourType)
listener.stateUpdateHandler = { _ in }
listener.newConnectionHandler = { $0.cancel() }
listener.start(queue: DispatchQueue(label: "expo.go.bonjour.probe"))
probeListener = listener
}

private func stopBonjourBrowser() {
pingTask?.cancel()
browser?.cancel()
probeListener?.cancel()
pingTask = nil
browser = nil
probeListener = nil
}

private func pingDiscoveryResults(_ results: [DiscoveryResult]) async {
Expand Down
7 changes: 7 additions & 0 deletions apps/expo-go/ios/Client/SwiftUI/Utils/UrlUtils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ func normalizeUrl(_ url: String) -> String {
return components.joined()
}

func isUpdatePermalink(_ url: String) -> Bool {
guard let components = URLComponents(string: url) else {
return false
}
return components.host == "u.expo.dev" && components.path.hasPrefix("/update/")
}

func sanitizeUrlString(_ urlString: String) -> String? {
var sanitizedUrl = urlString.trimmingCharacters(in: .whitespacesAndNewlines)

Expand Down
3 changes: 3 additions & 0 deletions apps/expo-go/ios/Client/SwiftUI/Views/BranchDetailsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ class BranchDetailsViewModel: ObservableObject {
branch = response.data.app.byId.updateBranchByName
hasLoadedRemote = true
} catch {
if (error as? APIError)?.isCancellation == true {
return
}
self.error = error
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright © 2025 650 Industries. All rights reserved.

import SwiftUI

struct NetworkPermissionBanner: View {
@ObservedObject var serverService: DevelopmentServerService
@State private var showingPermissionFlow = false

var body: some View {
Group {
// `showingPermissionFlow` keeps the banner alive while its sheet is up: the grant is detected the
// moment the system prompt appears, and hiding the banner then would tear the sheet down with it.
if showingPermissionFlow
|| (!DevelopmentServerService.isSimulator
&& !serverService.hasGrantedNetworkPermission
&& serverService.permissionStatus != .granted) {
Button {
showingPermissionFlow = true
} label: {
HStack {
Image(systemName: "wifi.exclamationmark")
.font(.title2)
.foregroundColor(.orange)
VStack(alignment: .leading, spacing: 4) {
Text("Local Network Access Needed")
.font(.subheadline)
.fontWeight(.semibold)
.foregroundColor(.primary)
Text("Projects running on your computer can't be discovered. Tap to enable access.")
.font(.footnote)
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.secondary)
}
.padding()
}
.buttonStyle(PlainButtonStyle())
.background(Color.expoSecondarySystemBackground)
.cornerRadius(18)
}
}
.sheet(isPresented: $showingPermissionFlow) {
LocalNetworkPermissionView(serverService: serverService) {
serverService.startDiscovery()
showingPermissionFlow = false
}
}
}
}
Loading
Loading