Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
565865774e | ||
|
|
63f7463ced | ||
|
|
0175c9e5a5 | ||
|
|
9f9bf14bbe | ||
|
|
a7783d46cf |
@@ -136,7 +136,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 100
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
@@ -178,7 +178,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 100
|
||||||
|
|
||||||
- name: Check runner tools
|
- name: Check runner tools
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+2
-2
@@ -224,7 +224,7 @@ tasks:
|
|||||||
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
desc: Build AAB via Dagger (cached, versionCode=1 placeholder) and export locally
|
||||||
cmds:
|
cmds:
|
||||||
- mkdir -p build/app/outputs/bundle/release
|
- mkdir -p build/app/outputs/bundle/release
|
||||||
- dagger call --progress=plain -q -m ci --source=. build-android-release -o build/app/outputs/bundle/release/app-release.aab
|
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. build-android-release --commit-hash "$HASH" -o build/app/outputs/bundle/release/app-release.aab
|
||||||
|
|
||||||
upload-android-bundle:
|
upload-android-bundle:
|
||||||
desc: Upload AAB from build/ to Play Store via Dagger
|
desc: Upload AAB from build/ to Play Store via Dagger
|
||||||
@@ -247,7 +247,7 @@ tasks:
|
|||||||
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
- sh: test -n "$ANDROID_KEYSTORE_PASSWORD"
|
||||||
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
msg: "ANDROID_KEYSTORE_PASSWORD is not set"
|
||||||
cmds:
|
cmds:
|
||||||
- dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD
|
- HASH=$(git rev-parse --short HEAD) && dagger call --progress=plain -q -m ci --source=. publish-android --play-store-config env:PLAY_STORE_CONFIG_JSON --keystore-base64 env:ANDROID_KEYSTORE_BASE64 --keystore-password env:ANDROID_KEYSTORE_PASSWORD --commit-hash "$HASH"
|
||||||
|
|
||||||
deploy-apk:
|
deploy-apk:
|
||||||
desc: Build and deploy Android APK via Dagger
|
desc: Build and deploy Android APK via Dagger
|
||||||
|
|||||||
+57
-13
@@ -286,6 +286,21 @@ func (m *Ci) firebaseSrc() *dagger.Directory {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// androidBase wraps setup(androidSrc()) with the Gradle named-cache so that
|
||||||
|
// Gradle dependencies survive across Dagger execution-cache misses.
|
||||||
|
func (m *Ci) androidBase() *dagger.Container {
|
||||||
|
return m.setup(m.androidSrc()).
|
||||||
|
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
|
||||||
|
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// firebaseBase wraps setup(firebaseSrc()) with the Gradle named-cache.
|
||||||
|
func (m *Ci) firebaseBase() *dagger.Container {
|
||||||
|
return m.setup(m.firebaseSrc()).
|
||||||
|
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"),
|
||||||
|
dagger.ContainerWithMountedCacheOpts{Owner: "ci"})
|
||||||
|
}
|
||||||
|
|
||||||
// linuxSrc is the source subset for Linux builds and integration tests.
|
// linuxSrc is the source subset for Linux builds and integration tests.
|
||||||
func (m *Ci) linuxSrc() *dagger.Directory {
|
func (m *Ci) linuxSrc() *dagger.Directory {
|
||||||
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
return m.Source.Filter(dagger.DirectoryFilterOpts{
|
||||||
@@ -584,9 +599,17 @@ func (m *Ci) BuildLinux() *dagger.Directory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BuildLinuxRelease builds the Linux release bundle.
|
// BuildLinuxRelease builds the Linux release bundle.
|
||||||
func (m *Ci) BuildLinuxRelease() *dagger.Directory {
|
func (m *Ci) BuildLinuxRelease(
|
||||||
|
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||||
|
// +optional
|
||||||
|
commitHash string,
|
||||||
|
) *dagger.Directory {
|
||||||
|
args := []string{"flutter", "build", "linux", "--release"}
|
||||||
|
if commitHash != "" {
|
||||||
|
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
||||||
|
}
|
||||||
return m.setup(m.linuxSrc()).
|
return m.setup(m.linuxSrc()).
|
||||||
WithExec([]string{"flutter", "build", "linux", "--release"}).
|
WithExec(args).
|
||||||
Directory("build/linux/x64/release/bundle")
|
Directory("build/linux/x64/release/bundle")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,7 +622,7 @@ func (m *Ci) DeployLinux(
|
|||||||
sshHost string,
|
sshHost string,
|
||||||
commitHash string,
|
commitHash string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
bundle := m.BuildLinuxRelease()
|
bundle := m.BuildLinuxRelease(commitHash)
|
||||||
|
|
||||||
datePath := time.Now().Format("2006/01/02")
|
datePath := time.Now().Format("2006/01/02")
|
||||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||||
@@ -615,16 +638,27 @@ func (m *Ci) DeployLinux(
|
|||||||
|
|
||||||
// setupKeystore decodes the base64 keystore into the android build container.
|
// setupKeystore decodes the base64 keystore into the android build container.
|
||||||
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
|
func (m *Ci) setupKeystore(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret) *dagger.Container {
|
||||||
return m.setup(m.androidSrc()).
|
return m.androidBase().
|
||||||
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
WithSecretVariable("ANDROID_KEYSTORE_BASE64", keystoreBase64).
|
||||||
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
WithSecretVariable("ANDROID_KEYSTORE_PASSWORD", keystorePassword).
|
||||||
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
|
WithExec([]string{"/bin/sh", "-c", `echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/upload-keystore.jks`})
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildAndroidApk builds a release APK signed with the upload key.
|
// BuildAndroidApk builds a release APK signed with the upload key.
|
||||||
func (m *Ci) BuildAndroidApk(keystoreBase64 *dagger.Secret, keystorePassword *dagger.Secret, buildNumber string) *dagger.File {
|
func (m *Ci) BuildAndroidApk(
|
||||||
|
keystoreBase64 *dagger.Secret,
|
||||||
|
keystorePassword *dagger.Secret,
|
||||||
|
buildNumber string,
|
||||||
|
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||||
|
// +optional
|
||||||
|
commitHash string,
|
||||||
|
) *dagger.File {
|
||||||
|
args := []string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}
|
||||||
|
if commitHash != "" {
|
||||||
|
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
||||||
|
}
|
||||||
return m.setupKeystore(keystoreBase64, keystorePassword).
|
return m.setupKeystore(keystoreBase64, keystorePassword).
|
||||||
WithExec([]string{"flutter", "build", "apk", "--release", "--no-pub", "--build-number", buildNumber}).
|
WithExec(args).
|
||||||
File("build/app/outputs/flutter-apk/app-release.apk")
|
File("build/app/outputs/flutter-apk/app-release.apk")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,7 +674,7 @@ func (m *Ci) DeployApk(
|
|||||||
keystorePassword *dagger.Secret,
|
keystorePassword *dagger.Secret,
|
||||||
buildNumber string,
|
buildNumber string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber)
|
apk := m.BuildAndroidApk(keystoreBase64, keystorePassword, buildNumber, commitHash)
|
||||||
|
|
||||||
datePath := time.Now().Format("2006/01/02")
|
datePath := time.Now().Format("2006/01/02")
|
||||||
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
remoteDir := fmt.Sprintf("public_html/builds/%s", datePath)
|
||||||
@@ -656,8 +690,7 @@ func (m *Ci) DeployApk(
|
|||||||
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
|
// BuildAndroidDebugApks builds the debug app APK and the androidTest APK needed for Firebase Test Lab.
|
||||||
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
// Returns a flat directory with app-debug.apk and app-debug-androidTest.apk.
|
||||||
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
func (m *Ci) BuildAndroidDebugApks() *dagger.Directory {
|
||||||
built := m.setup(m.firebaseSrc()).
|
built := m.firebaseBase().
|
||||||
WithMountedCache("/home/ci/.gradle", dag.CacheVolume("gradle-cache"), dagger.ContainerWithMountedCacheOpts{Owner: "ci"}).
|
|
||||||
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
WithExec([]string{"flutter", "build", "apk", "--debug", "--no-pub"}).
|
||||||
WithWorkdir("/src/android").
|
WithWorkdir("/src/android").
|
||||||
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
// --no-daemon avoids connecting to a stale daemon whose registry file was
|
||||||
@@ -716,9 +749,17 @@ func (m *Ci) TestAndroidFirebase(
|
|||||||
|
|
||||||
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
|
// BuildAndroidRelease builds the AAB with a fixed build-number so Dagger can cache it.
|
||||||
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
|
// versionCode and signing are applied separately via StampAndroidVersionCode + SignAndroidBundle.
|
||||||
func (m *Ci) BuildAndroidRelease() *dagger.File {
|
func (m *Ci) BuildAndroidRelease(
|
||||||
return m.setup(m.androidSrc()).
|
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||||
WithExec([]string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}).
|
// +optional
|
||||||
|
commitHash string,
|
||||||
|
) *dagger.File {
|
||||||
|
args := []string{"flutter", "build", "appbundle", "--release", "--no-pub", "--build-number", "1"}
|
||||||
|
if commitHash != "" {
|
||||||
|
args = append(args, "--dart-define=GIT_HASH="+commitHash)
|
||||||
|
}
|
||||||
|
return m.androidBase().
|
||||||
|
WithExec(args).
|
||||||
File("build/app/outputs/bundle/release/app-release.aab")
|
File("build/app/outputs/bundle/release/app-release.aab")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,9 +831,12 @@ func (m *Ci) PublishAndroid(
|
|||||||
playStoreConfig *dagger.Secret,
|
playStoreConfig *dagger.Secret,
|
||||||
keystoreBase64 *dagger.Secret,
|
keystoreBase64 *dagger.Secret,
|
||||||
keystorePassword *dagger.Secret,
|
keystorePassword *dagger.Secret,
|
||||||
|
// Git commit hash injected as GIT_HASH dart-define so the About page can display it.
|
||||||
|
// +optional
|
||||||
|
commitHash string,
|
||||||
) (string, error) {
|
) (string, error) {
|
||||||
versionCode := int(time.Now().Unix())
|
versionCode := int(time.Now().Unix())
|
||||||
aab := m.BuildAndroidRelease()
|
aab := m.BuildAndroidRelease(commitHash)
|
||||||
stamped := m.StampAndroidVersionCode(aab, versionCode)
|
stamped := m.StampAndroidVersionCode(aab, versionCode)
|
||||||
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
|
signed := m.SignAndroidBundle(stamped, keystoreBase64, keystorePassword)
|
||||||
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
|
return m.UploadToPlayStore(ctx, signed, playStoreConfig)
|
||||||
|
|||||||
@@ -360,7 +360,12 @@ class _EditAccountScreenState extends ConsumerState<EditAccountScreen> {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
FilledButton(onPressed: _save, child: const Text('Save')),
|
FilledButton(
|
||||||
|
onPressed: _hasStoredPassword || _passwordCtrl.text.isNotEmpty
|
||||||
|
? _save
|
||||||
|
: null,
|
||||||
|
child: const Text('Save'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import re
|
|||||||
import shlex
|
import shlex
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -278,6 +279,41 @@ def _merge_pr(pr_number: int) -> None:
|
|||||||
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
|
_fgj("pr", "merge", str(pr_number), "--repo", REPO, "--merge-method", "squash")
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_pr_still_open_after_merge(pr_number: int, branch: str, issue_num: int | None) -> str:
|
||||||
|
"""Handle a PR that is still open after a successful _merge_pr() call.
|
||||||
|
|
||||||
|
Returns one of:
|
||||||
|
"rebase-spawned" — merge conflict detected; rebase agent started, state written
|
||||||
|
"merged" — PR closed after a retry
|
||||||
|
"fallback" — all options exhausted; caller should set State/Question
|
||||||
|
"""
|
||||||
|
pr_data = _tea_get(f"repos/{REPO}/pulls/{pr_number}")
|
||||||
|
mergeable = (pr_data or {}).get("mergeable")
|
||||||
|
|
||||||
|
if mergeable is False:
|
||||||
|
prompt = (
|
||||||
|
f"Rebase branch `{branch}` onto main to resolve merge conflicts, then push. "
|
||||||
|
"Do not change any logic — only resolve conflicts and push."
|
||||||
|
)
|
||||||
|
session_name = f"rebase-pr-{pr_number}"
|
||||||
|
pid = _start_agent(prompt, session_name)
|
||||||
|
_write_state(pid, issue_num, "pending-ci", session_name=session_name)
|
||||||
|
print(f"PR #{pr_number} has merge conflicts — spawned rebase agent (pid={pid}).")
|
||||||
|
return "rebase-spawned"
|
||||||
|
|
||||||
|
for attempt in range(1, 3):
|
||||||
|
time.sleep(5)
|
||||||
|
try:
|
||||||
|
_merge_pr(pr_number)
|
||||||
|
except RuntimeError as e:
|
||||||
|
print(f"PR #{pr_number} merge retry {attempt} failed: {e}")
|
||||||
|
if not _find_pr_for_branch(branch):
|
||||||
|
print(f"PR #{pr_number} merged on retry {attempt}.")
|
||||||
|
return "merged"
|
||||||
|
|
||||||
|
return "fallback"
|
||||||
|
|
||||||
|
|
||||||
# ── state file ────────────────────────────────────────────────────────────────
|
# ── state file ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -676,6 +712,13 @@ def _run_loop() -> int:
|
|||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
if _find_pr_for_branch(branch):
|
if _find_pr_for_branch(branch):
|
||||||
|
merge_result = _handle_pr_still_open_after_merge(pr_number, branch, pending_issue)
|
||||||
|
if merge_result == "rebase-spawned":
|
||||||
|
return 0
|
||||||
|
if merge_result == "merged":
|
||||||
|
_close_issue(pending_issue)
|
||||||
|
print(f"Merged PR #{pr_number} and closed {_issue_url(pending_issue)}.")
|
||||||
|
return 0
|
||||||
print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.")
|
print(f"PR #{pr_number} is still open after merge attempt — setting to State/Question.")
|
||||||
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
_set_labels(pending_issue, add=[LABEL_QUESTION], remove=[LABEL_IN_PROGRESS])
|
||||||
_comment_issue(
|
_comment_issue(
|
||||||
@@ -744,6 +787,16 @@ def _run_loop() -> int:
|
|||||||
# Verify the merge actually happened; fgj can exit 0 without merging
|
# Verify the merge actually happened; fgj can exit 0 without merging
|
||||||
# (e.g. branch-protection rules not satisfied).
|
# (e.g. branch-protection rules not satisfied).
|
||||||
if _find_pr_for_branch(branch):
|
if _find_pr_for_branch(branch):
|
||||||
|
merge_result = _handle_pr_still_open_after_merge(pr_number, branch, issue_num)
|
||||||
|
if merge_result == "rebase-spawned":
|
||||||
|
return 0
|
||||||
|
if merge_result == "merged":
|
||||||
|
if issue_num:
|
||||||
|
_close_issue(issue_num)
|
||||||
|
print(f"Catch-up: merged PR #{pr_number} and closed issue #{issue_num} after retry.")
|
||||||
|
else:
|
||||||
|
print(f"Catch-up: merged PR #{pr_number} after retry.")
|
||||||
|
return 0
|
||||||
print(
|
print(
|
||||||
f"Catch-up: PR #{pr_number} is still open after merge attempt "
|
f"Catch-up: PR #{pr_number} is still open after merge attempt "
|
||||||
"— skipping to avoid infinite retry."
|
"— skipping to avoid infinite retry."
|
||||||
|
|||||||
@@ -785,6 +785,90 @@ class TestCatchupSkipsQuestionIssues(unittest.TestCase):
|
|||||||
mock_merge.assert_called_once_with(50)
|
mock_merge.assert_called_once_with(50)
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergeFailsOpen(unittest.TestCase):
|
||||||
|
"""Tests for auto-resolution when a PR is still open after the merge command."""
|
||||||
|
|
||||||
|
def _dead_state(self, issue: int, kind: str = "issue") -> dict:
|
||||||
|
return {
|
||||||
|
"pid": 999999999,
|
||||||
|
"issue": issue,
|
||||||
|
"started_at": "2026-01-01T00:00:00+00:00",
|
||||||
|
"type": kind,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _open_pr(self, branch: str = "issue-10-fix") -> dict:
|
||||||
|
return {"number": 5, "head": {"ref": branch}, "created_at": "2026-01-01T00:00:00+00:00"}
|
||||||
|
|
||||||
|
def test_merge_fails_open_with_conflicts_spawns_rebase_agent(self):
|
||||||
|
"""mergeable=false → rebase agent spawned, state written as pending-ci."""
|
||||||
|
written_state = {}
|
||||||
|
|
||||||
|
def fake_write_state(pid, issue, kind, issue_title=None, session_name=None, ci_run_id=None):
|
||||||
|
written_state["pid"] = pid
|
||||||
|
written_state["issue"] = issue
|
||||||
|
written_state["kind"] = kind
|
||||||
|
written_state["session_name"] = session_name
|
||||||
|
|
||||||
|
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||||
|
patch("agent_loop._find_pr_for_branch", side_effect=[self._open_pr(), self._open_pr()]), \
|
||||||
|
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||||
|
patch("agent_loop._merge_pr"), \
|
||||||
|
patch("agent_loop._tea_get", return_value={"mergeable": False}), \
|
||||||
|
patch("agent_loop._start_agent", return_value=77) as mock_start, \
|
||||||
|
patch("agent_loop._write_state", side_effect=fake_write_state), \
|
||||||
|
patch("agent_loop._clear_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_start.assert_called_once()
|
||||||
|
prompt = mock_start.call_args[0][0]
|
||||||
|
self.assertIn("Rebase branch", prompt)
|
||||||
|
self.assertIn("issue-10-fix", prompt)
|
||||||
|
self.assertEqual(written_state.get("kind"), "pending-ci")
|
||||||
|
self.assertEqual(written_state.get("issue"), 10)
|
||||||
|
|
||||||
|
def test_merge_fails_open_no_conflicts_retries_and_succeeds(self):
|
||||||
|
"""mergeable=true, second attempt succeeds → issue closed."""
|
||||||
|
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||||
|
patch("agent_loop._find_pr_for_branch",
|
||||||
|
side_effect=[self._open_pr(), self._open_pr(), None]), \
|
||||||
|
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||||
|
patch("agent_loop._merge_pr"), \
|
||||||
|
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
|
||||||
|
patch("agent_loop.time.sleep"), \
|
||||||
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
|
patch("agent_loop._clear_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_close.assert_called_once_with(10)
|
||||||
|
|
||||||
|
def test_merge_fails_open_no_conflicts_all_retries_exhausted(self):
|
||||||
|
"""All retries exhausted with PR still open → falls through to State/Question."""
|
||||||
|
with patch("agent_loop._read_state", return_value=self._dead_state(10)), \
|
||||||
|
patch("agent_loop._find_pr_for_branch",
|
||||||
|
side_effect=[self._open_pr(), self._open_pr(),
|
||||||
|
self._open_pr(), self._open_pr()]), \
|
||||||
|
patch("agent_loop._latest_ci_run_for_branch", return_value={"id": 1, "status": "success"}), \
|
||||||
|
patch("agent_loop._merge_pr"), \
|
||||||
|
patch("agent_loop._tea_get", return_value={"mergeable": True}), \
|
||||||
|
patch("agent_loop.time.sleep"), \
|
||||||
|
patch("agent_loop._set_labels") as mock_labels, \
|
||||||
|
patch("agent_loop._comment_issue") as mock_comment, \
|
||||||
|
patch("agent_loop._close_issue") as mock_close, \
|
||||||
|
patch("agent_loop._clear_state"):
|
||||||
|
result = agent_loop._run_loop()
|
||||||
|
|
||||||
|
self.assertEqual(result, 0)
|
||||||
|
mock_close.assert_not_called()
|
||||||
|
mock_labels.assert_called_once_with(
|
||||||
|
10,
|
||||||
|
add=[agent_loop.LABEL_QUESTION],
|
||||||
|
remove=[agent_loop.LABEL_IN_PROGRESS],
|
||||||
|
)
|
||||||
|
mock_comment.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class TestHeartbeat(unittest.TestCase):
|
class TestHeartbeat(unittest.TestCase):
|
||||||
"""Tests for _update_heartbeat() and cmd_monitor()."""
|
"""Tests for _update_heartbeat() and cmd_monitor()."""
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,30 @@ void main() {
|
|||||||
expect(button.onPressed, isNotNull);
|
expect(button.onPressed, isNotNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('save button is disabled when no password stored or entered', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
tester.view.physicalSize = const Size(800, 1400);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
buildApp(
|
||||||
|
initialLocation: '/accounts/acc-1/edit',
|
||||||
|
overrides: baseOverrides(
|
||||||
|
accounts: [kTestAccount],
|
||||||
|
hasStoredPassword: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final button = tester
|
||||||
|
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Save'));
|
||||||
|
expect(button.onPressed, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
testWidgets('connection error shows error message', (tester) async {
|
testWidgets('connection error shows error message', (tester) async {
|
||||||
tester.view.physicalSize = const Size(800, 1400);
|
tester.view.physicalSize = const Size(800, 1400);
|
||||||
tester.view.devicePixelRatio = 1.0;
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
|||||||
Reference in New Issue
Block a user