From e17ae670854f8be31843dfada918adcd154ead2c Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 29 Sep 2026 17:12:01 +0100 Subject: [PATCH] Sort Firebase project choices by relevance and recency --- bin/config.sh | 56 ++++++++++++++++++++++++++++++++++------ bin/questions.sh | 15 ++++++----- tests/agent-questions.sh | 18 +++++++------ tests/approval-gate.sh | 24 +++++++++++++++++ 4 files changed, 90 insertions(+), 23 deletions(-) diff --git a/bin/config.sh b/bin/config.sh index a6160f0..fbb309d 100755 --- a/bin/config.sh +++ b/bin/config.sh @@ -1335,16 +1335,27 @@ quota_project() { node -e 'try{process.stdout.write(require(process.env.HOME+"/.config/gcloud/application_default_credentials.json").quota_project_id||"")}catch(e){}' 2>/dev/null } +# Cloud Resource Manager carries the creation time that FirebaseProject omits. +# Newest first also makes a recently created Firebase project a useful quota +# candidate instead of letting an arbitrary API order hide it beyond the limit. +cloud_projects() { + gcloud projects list --format='value(projectId,createTime)' \ + --sort-by='~createTime' --limit="${LIMIT:-50}" 2>/dev/null +} + # Quota-project candidates for the project *list*, which runs before any project -# is chosen. Best guess first; duplicates dropped. +# is chosen. Best guess first; duplicates dropped. firebase_projects passes the +# Cloud Resource Manager listing it already fetched so this does not read twice. quota_candidates() { + local projects + if (($#)); then projects="${1-}"; else projects="$(cloud_projects)"; fi { [[ -s "$WS/.quota" ]] && { tr -d '\n' < "$WS/.quota"; echo; } local p p="$(gcloud config get-value project 2>/dev/null)" [[ -n "$p" && "$p" != "(unset)" ]] && echo "$p" node -e 'try{const q=require(process.env.HOME+"/.config/gcloud/application_default_credentials.json").quota_project_id;if(q)console.log(q)}catch(e){}' 2>/dev/null - gcloud projects list --format='value(projectId)' --limit="${LIMIT:-50}" 2>/dev/null + printf '%s\n' "$projects" | cut -f1 } | awk 'NF && !seen[$0]++' } @@ -1753,15 +1764,44 @@ firebase_projects() { # instead of having to remember it. rc 10 is "a human is needed", which is what an # unasked question is — and it is not rc 1, so nothing reads it as "Google said no". may_list_projects || return 10 - local url='https://firebase.googleapis.com/v1beta1/projects?pageSize=100' body p + local base='https://firebase.googleapis.com/v1beta1/projects?pageSize=100' + local body cloud page page_token p projects url + cloud="$(cloud_projects)" || cloud="" while read -r p; do - body="$(QP="$p" api_get "$url" 2>/dev/null)" || continue + projects=""; page_token="" + while :; do + url="$base" + [[ -n "$page_token" ]] && url="$url&pageToken=$(urlenc "$page_token")" + body="$(QP="$p" api_get "$url" 2>/dev/null)" || { projects=""; break; } + page="$(printf '%s' "$body" | jqn 'process.stdout.write((j.results||[]) + .filter(p=>!p.state||p.state==="ACTIVE") + .map(p=>[p.projectId,p.displayName||""].join("\t")).join("\n"))')" + [[ -n "$page" ]] && projects="${projects}${projects:+$'\n'}${page}" + page_token="$(printf '%s' "$body" | jqn 'process.stdout.write(j.nextPageToken||"")')" + [[ -n "$page_token" ]] || break + done + [[ -n "$projects" ]] || continue ws_init; printf '%s' "$p" > "$WS/.quota" - printf '%s' "$body" | jqn 'process.stdout.write((j.results||[]) - .filter(p=>!p.state||p.state==="ACTIVE") - .map(p=>[p.projectId,p.displayName||""].join("\t")).join("\n"))' + { + printf '%s\n' "$cloud" + printf '\036\n' + printf '%s\n' "$projects" + } | node -e ' + let s = ""; process.stdin.on("data", d => s += d).on("end", () => { + const [cloud = "", firebase = ""] = s.split("\n\u001e\n"); + const created = new Map(cloud.split("\n").filter(Boolean).map(line => { + const [id, time = ""] = line.split("\t"); return [id, time]; + })); + firebase.split("\n").filter(Boolean) + .map((line, i) => { + const [id] = line.split("\t"); + return { line, i, time: created.get(id) || "" }; + }) + .sort((a, b) => b.time.localeCompare(a.time) || a.i - b.i) + .forEach(({ line }) => console.log(line)); + });' return 0 - done <<< "$(quota_candidates)" + done <<< "$(quota_candidates "$cloud")" return 1 } diff --git a/bin/questions.sh b/bin/questions.sh index c56d5cb..e024b91 100644 --- a/bin/questions.sh +++ b/bin/questions.sh @@ -258,18 +258,19 @@ EOF # and a picker that re-reads their account is a second helping of a one-time yes. # id \t display name \t app count \t carries our package \t packages — best first. -# Ranked so the zero-mutation answer is the first thing read: a project that already has -# this package needs nothing created in it at all. +# Exact package matches are the zero-mutation answer, so they come first. Discovery +# already returns projects newest first, and the stable tie-break keeps that order +# within the matching and non-matching groups. ranked_projects() { [[ -s "$WS/discovered.json" ]] || return 1 PKG="${PACKAGE:-}" node -e ' let s=""; process.stdin.on("data", d => s += d).on("end", () => { const want = process.env.PKG || "", d = JSON.parse(s); - const tier = p => (p.apps || []).some(a => a.package === want) ? 0 : ((p.apps || []).length ? 1 : 2); - (d.projects || []).map((p, i) => ({ p, i, t: tier(p) })) - .sort((a, b) => a.t - b.t || a.i - b.i) - .forEach(({ p, t }) => console.log([ - p.id, p.name || "", (p.apps || []).length, t === 0 ? 1 : 0, + const matches = p => !!want && (p.apps || []).some(a => a.package === want); + (d.projects || []).map((p, i) => ({ p, i, match: matches(p) })) + .sort((a, b) => Number(b.match) - Number(a.match) || a.i - b.i) + .forEach(({ p, match }) => console.log([ + p.id, p.name || "", (p.apps || []).length, match ? 1 : 0, (p.apps || []).map(a => a.package).join(", "), ].join("\t"))); });' < "$WS/discovered.json" 2>/dev/null diff --git a/tests/agent-questions.sh b/tests/agent-questions.sh index e150a7f..506abeb 100755 --- a/tests/agent-questions.sh +++ b/tests/agent-questions.sh @@ -464,11 +464,13 @@ echo DISC="$TMP/disc"; mkdir -p "$DISC" node -e ' const projects = []; -for (let i = 1; i <= 12; i++) projects.push({ id: `proj-number-${i}`, name: `Project ${i}`, apps: [] }); -projects[4].apps = [{ package: "com.other.thing", appId: "1:5:android:aaa" }]; -projects[7].apps = [{ package: "com.dogshelter", appId: "1:8:android:bbb" }]; -projects[10].apps = [{ package: "com.dogshelter", appId: "1:11:android:ccc" }, - { package: "com.dogshelter.dev", appId: "1:11:android:ddd" }]; +for (let i = 12; i >= 1; i--) projects.push({ id: `proj-number-${i}`, name: `Project ${i}`, apps: [] }); +projects.find(p => p.id === "proj-number-5").apps = [{ package: "com.other.thing", appId: "1:5:android:aaa" }]; +projects.find(p => p.id === "proj-number-8").apps = [{ package: "com.dogshelter", appId: "1:8:android:bbb" }]; +projects.find(p => p.id === "proj-number-11").apps = [ + { package: "com.dogshelter", appId: "1:11:android:ccc" }, + { package: "com.dogshelter.dev", appId: "1:11:android:ddd" }, +]; process.stdout.write(JSON.stringify({ account: "dev@example.com", projects })); ' > "$DISC/discovered.json" @@ -498,9 +500,9 @@ proj="$(qd pick_project)" # design: a developer whose answer is on page two will type it, and one whose answer is # ranked first will click it. labels="$(node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>JSON.parse(s).options.forEach(o=>console.log(o.label)))' <<< "$proj")" -[[ "$(head -1 <<< "$labels")" == proj-number-8 || "$(head -1 <<< "$labels")" == proj-number-11 ]] \ - && ok "a project that already has the package is offered first — the path that creates nothing" \ - || bad "the zero-mutation project is not first: $(tr '\n' ' ' <<< "$labels")" +[[ "$(sed -n '1,3p' <<< "$labels")" == $'proj-number-11\nproj-number-8\nproj-number-12' ]] \ + && ok "package matches come first, then projects are newest first" \ + || bad "the project order is not package match then recency: $(tr '\n' ' ' <<< "$labels")" grep -qF "9 more not shown" <<< "$proj" \ && ok "and it admits how many it left out, rather than implying that was all of them" \ || bad "twelve projects, three shown, and nothing says so" diff --git a/tests/approval-gate.sh b/tests/approval-gate.sh index 0ba5b3b..4af6d7d 100755 --- a/tests/approval-gate.sh +++ b/tests/approval-gate.sh @@ -294,6 +294,30 @@ rc=$? (( $(http_calls "$WS12") == 0 )) && ok "and made no call while refusing" \ || bad "firebase_projects reached the network before refusing" +PAGED="$TMP/paged-projects"; mkdir -p "$PAGED" +projects="$(WS="$PAGED" APPROVED=1 bash -c ' + source bin/config.sh >/dev/null 2>&1 + may_list_projects() { return 0; } + cloud_projects() { + printf "%s\n" \ + $'"'"'new-project\t2026-09-29T10:00:00Z'"'"' \ + $'"'"'old-project\t2025-01-10T10:00:00Z'"'"' + } + quota_candidates() { echo quota-project; } + api_get() { + case "$1" in + *pageToken=*) + printf "%s" '"'"'{"results":[{"projectId":"new-project","displayName":"New project","state":"ACTIVE"}]}'"'"' ;; + *) + printf "%s" '"'"'{"results":[{"projectId":"old-project","displayName":"Old project","state":"ACTIVE"}],"nextPageToken":"second page"}'"'"' ;; + esac + } + firebase_projects +' < /dev/null)" +[[ "$projects" == $'new-project\tNew project\nold-project\tOld project' ]] \ + && ok "firebase_projects reads every page and returns newest projects first" \ + || bad "firebase_projects lost a page or did not sort by creation time: $(tr '\n' ' ' <<< "$projects")" + echo echo " A pasted value cannot grant its own consent" echo