From 5db01fbf4841ebc2e851ddbdd64c6ad4d9a3ee16 Mon Sep 17 00:00:00 2001 From: Dany Khalife Date: Sun, 21 Jun 2026 06:21:33 -0700 Subject: [PATCH 1/5] Add Azure DevOps api-build pipeline extending Pipeline.Templates Equivalent of the api-build GitHub Actions workflow, expressed as an intent-only consumer of pipeline.yml@templates. Reaches the GitHub-hosted source and template repo via the "github" service connection. --- .azuredevops/api-build.yml | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .azuredevops/api-build.yml diff --git a/.azuredevops/api-build.yml b/.azuredevops/api-build.yml new file mode 100644 index 0000000..8189653 --- /dev/null +++ b/.azuredevops/api-build.yml @@ -0,0 +1,48 @@ +name: $(Date:yyMMdd).00.$(Rev:r) + +trigger: + branches: + include: + - main + paths: + include: + - apiserver/** + +pr: + branches: + include: + - main + paths: + include: + - apiserver/** + +resources: + repositories: + - repository: templates + type: github + name: dkhalife/Pipeline.Templates + endpoint: github + ref: refs/tags/latest + +extends: + template: pipeline.yml@templates + parameters: + stages: + - stage: build + templateContext: + intent: build + jobs: + - job: app + templateContext: + go: + - name: api + goVersion: '1.25' + workingDirectory: apiserver + buildArgs: >- + -v + -ldflags "-X dkhalife.com/tasks/core/internal/version.Version=$(Build.BuildNumber) + -X dkhalife.com/tasks/core/internal/version.BuildNumber=0 + -X dkhalife.com/tasks/core/internal/version.CommitHash=$(Build.SourceVersion)" + ./... + testArgs: '-v -coverprofile=coverage.txt ./...' + vet: false From c8181d5de84a8cfe00c7f8a76f22004d011abca9 Mon Sep 17 00:00:00 2001 From: Dany Khalife Date: Sun, 21 Jun 2026 06:59:02 -0700 Subject: [PATCH 2/5] Fix golangci-lint v2 findings in apiserver The ADO pipeline runs golangci-lint v2, which is stricter than the v1.64.8 the GitHub workflow used (no default exclusion presets; full staticcheck suite). Resolve all findings: - errcheck: check/ignore error returns on Close/Remove/Setenv/Unsetenv across config, ws, notifications and database tests + http response bodies in gotify/webhook. - staticcheck QF1008: db.Dialector.Name() -> db.Name() in migrations. - staticcheck QF1003: tagged switch on freq.Unit in task repo. - staticcheck ST1023: drop redundant time.Time type in task service. Verified: go build, go test (serial), and golangci-lint v2.12.2 all clean. --- apiserver/config/config_test.go | 76 +++++++++---------- .../internal/migrations/001_initial_schema.go | 2 +- .../internal/migrations/002_entra_auth.go | 4 +- .../internal/migrations/003_drop_password.go | 2 +- .../migrations/004_drop_app_tokens.go | 2 +- .../internal/migrations/005_drop_email.go | 4 +- .../migrations/006_account_deletion.go | 4 +- apiserver/internal/migrations/007_sessions.go | 2 +- .../migrations/008_unique_label_names.go | 4 +- .../009_task_histories_task_id_id_index.go | 4 +- apiserver/internal/repos/task/task.go | 11 +-- .../internal/services/notifications/gotify.go | 2 +- .../services/notifications/safehttp_test.go | 2 +- .../services/notifications/webhook.go | 2 +- apiserver/internal/services/tasks/task.go | 2 +- .../internal/utils/database/database_test.go | 2 +- apiserver/internal/utils/test/database.go | 2 +- apiserver/internal/ws/server_test.go | 16 ++-- 18 files changed, 72 insertions(+), 71 deletions(-) diff --git a/apiserver/config/config_test.go b/apiserver/config/config_test.go index 7693773..12f6c38 100644 --- a/apiserver/config/config_test.go +++ b/apiserver/config/config_test.go @@ -15,8 +15,8 @@ func TestLoadConfig_Success(t *testing.T) { f, err := os.Create("./config/config.yaml") assert.NoError(t, err, "failed to create config.yaml") - defer os.Remove("./config/config.yaml") - defer f.Close() + defer func() { _ = os.Remove("./config/config.yaml") }() + defer func() { _ = f.Close() }() _, err = f.WriteString(`server: port: 1234 log_level: debug @@ -112,8 +112,8 @@ func TestLoadConfig_EntraEnvOverride(t *testing.T) { _ = os.MkdirAll("./config", 0755) f, err := os.Create("./config/config.yaml") assert.NoError(t, err) - defer os.Remove("./config/config.yaml") - defer f.Close() + defer func() { _ = os.Remove("./config/config.yaml") }() + defer func() { _ = f.Close() }() _, err = f.WriteString(`entra: enabled: false @@ -125,10 +125,10 @@ server: `) assert.NoError(t, err) - os.Setenv("TW_ENTRA_ENABLED", "true") - os.Setenv("TW_ENTRA_TENANT_ID", "env-tenant") - os.Setenv("TW_ENTRA_CLIENT_ID", "env-client") - os.Setenv("TW_ENTRA_AUDIENCE", "api://env-client") + _ = os.Setenv("TW_ENTRA_ENABLED", "true") + _ = os.Setenv("TW_ENTRA_TENANT_ID", "env-tenant") + _ = os.Setenv("TW_ENTRA_CLIENT_ID", "env-client") + _ = os.Setenv("TW_ENTRA_AUDIENCE", "api://env-client") viper.Reset() cfg := LoadConfig("./config/config.yaml") @@ -138,56 +138,56 @@ server: assert.Equal(t, "env-client", cfg.Entra.ClientID) assert.Equal(t, "api://env-client", cfg.Entra.Audience) - os.Unsetenv("TW_ENTRA_ENABLED") - os.Unsetenv("TW_ENTRA_TENANT_ID") - os.Unsetenv("TW_ENTRA_CLIENT_ID") - os.Unsetenv("TW_ENTRA_AUDIENCE") + _ = os.Unsetenv("TW_ENTRA_ENABLED") + _ = os.Unsetenv("TW_ENTRA_TENANT_ID") + _ = os.Unsetenv("TW_ENTRA_CLIENT_ID") + _ = os.Unsetenv("TW_ENTRA_AUDIENCE") } func TestLoadConfig_EnvFile(t *testing.T) { f, err := os.Create("envconfig.yaml") assert.NoError(t, err) - defer os.Remove("envconfig.yaml") - defer f.Close() + defer func() { _ = os.Remove("envconfig.yaml") }() + defer func() { _ = f.Close() }() _, err = f.WriteString("server:\n port: 4444\n") assert.NoError(t, err) - os.Setenv("TW_CONFIG_FILE", "envconfig.yaml") + _ = os.Setenv("TW_CONFIG_FILE", "envconfig.yaml") viper.Reset() cfg := LoadConfig("") assert.Equal(t, 4444, cfg.Server.Port) - os.Unsetenv("TW_CONFIG_FILE") + _ = os.Unsetenv("TW_CONFIG_FILE") } func TestLoadConfig_CLIOverridesEnv(t *testing.T) { f1, err := os.Create("env.yaml") assert.NoError(t, err) - defer os.Remove("env.yaml") - defer f1.Close() + defer func() { _ = os.Remove("env.yaml") }() + defer func() { _ = f1.Close() }() _, err = f1.WriteString("server:\n port: 3333\n") assert.NoError(t, err) f2, err := os.Create("cli.yaml") assert.NoError(t, err) - defer os.Remove("cli.yaml") - defer f2.Close() + defer func() { _ = os.Remove("cli.yaml") }() + defer func() { _ = f2.Close() }() _, err = f2.WriteString("server:\n port: 2222\n") assert.NoError(t, err) - os.Setenv("TW_CONFIG_FILE", "env.yaml") + _ = os.Setenv("TW_CONFIG_FILE", "env.yaml") viper.Reset() cfg := LoadConfig("cli.yaml") assert.Equal(t, 2222, cfg.Server.Port) - os.Unsetenv("TW_CONFIG_FILE") + _ = os.Unsetenv("TW_CONFIG_FILE") } func TestLoadConfig_DatabaseEnvOverride(t *testing.T) { _ = os.MkdirAll("./config", 0755) f, err := os.Create("./config/config.yaml") assert.NoError(t, err) - defer os.Remove("./config/config.yaml") - defer f.Close() + defer func() { _ = os.Remove("./config/config.yaml") }() + defer func() { _ = f.Close() }() _, err = f.WriteString(`database: type: sqlite @@ -197,12 +197,12 @@ server: `) assert.NoError(t, err) - os.Setenv("TW_DATABASE_TYPE", "mysql") - os.Setenv("TW_DATABASE_HOST", "localhost") - os.Setenv("TW_DATABASE_PORT", "3307") - os.Setenv("TW_DATABASE_NAME", "taskwizard") - os.Setenv("TW_DATABASE_USERNAME", "dbuser") - os.Setenv("TW_DATABASE_PASSWORD", "dbpass") + _ = os.Setenv("TW_DATABASE_TYPE", "mysql") + _ = os.Setenv("TW_DATABASE_HOST", "localhost") + _ = os.Setenv("TW_DATABASE_PORT", "3307") + _ = os.Setenv("TW_DATABASE_NAME", "taskwizard") + _ = os.Setenv("TW_DATABASE_USERNAME", "dbuser") + _ = os.Setenv("TW_DATABASE_PASSWORD", "dbpass") viper.Reset() cfg := LoadConfig("./config/config.yaml") @@ -214,20 +214,20 @@ server: assert.Equal(t, "dbuser", cfg.Database.Username) assert.Equal(t, "dbpass", cfg.Database.Password) - os.Unsetenv("TW_DATABASE_TYPE") - os.Unsetenv("TW_DATABASE_HOST") - os.Unsetenv("TW_DATABASE_PORT") - os.Unsetenv("TW_DATABASE_NAME") - os.Unsetenv("TW_DATABASE_USERNAME") - os.Unsetenv("TW_DATABASE_PASSWORD") + _ = os.Unsetenv("TW_DATABASE_TYPE") + _ = os.Unsetenv("TW_DATABASE_HOST") + _ = os.Unsetenv("TW_DATABASE_PORT") + _ = os.Unsetenv("TW_DATABASE_NAME") + _ = os.Unsetenv("TW_DATABASE_USERNAME") + _ = os.Unsetenv("TW_DATABASE_PASSWORD") } func TestLoadConfig_MySQLConfig(t *testing.T) { _ = os.MkdirAll("./config", 0755) f, err := os.Create("./config/config.yaml") assert.NoError(t, err) - defer os.Remove("./config/config.yaml") - defer f.Close() + defer func() { _ = os.Remove("./config/config.yaml") }() + defer func() { _ = f.Close() }() _, err = f.WriteString(`database: type: mysql diff --git a/apiserver/internal/migrations/001_initial_schema.go b/apiserver/internal/migrations/001_initial_schema.go index 355ffda..625faf6 100644 --- a/apiserver/internal/migrations/001_initial_schema.go +++ b/apiserver/internal/migrations/001_initial_schema.go @@ -22,7 +22,7 @@ func (m *InitialSchemaMigration) Name() string { } func (m *InitialSchemaMigration) Up(ctx context.Context, db *gorm.DB) error { - dialect := db.Dialector.Name() + dialect := db.Name() var ( autoInc string diff --git a/apiserver/internal/migrations/002_entra_auth.go b/apiserver/internal/migrations/002_entra_auth.go index 9930952..fbafa40 100644 --- a/apiserver/internal/migrations/002_entra_auth.go +++ b/apiserver/internal/migrations/002_entra_auth.go @@ -24,7 +24,7 @@ func (m *EntraAuthMigration) Name() string { func (m *EntraAuthMigration) Up(ctx context.Context, db *gorm.DB) error { dbCtx := db.WithContext(ctx) migrator := dbCtx.Migrator() - dialect := db.Dialector.Name() + dialect := db.Name() var colType string switch dialect { @@ -88,7 +88,7 @@ func (m *EntraAuthMigration) Up(ctx context.Context, db *gorm.DB) error { func (m *EntraAuthMigration) Down(ctx context.Context, db *gorm.DB) error { dbCtx := db.WithContext(ctx) migrator := dbCtx.Migrator() - dialect := db.Dialector.Name() + dialect := db.Name() if migrator.HasIndex("users", "idx_users_entra_id") { if err := migrator.DropIndex("users", "idx_users_entra_id"); err != nil { diff --git a/apiserver/internal/migrations/003_drop_password.go b/apiserver/internal/migrations/003_drop_password.go index 4f8d374..6c9484b 100644 --- a/apiserver/internal/migrations/003_drop_password.go +++ b/apiserver/internal/migrations/003_drop_password.go @@ -37,7 +37,7 @@ func (m *DropPasswordMigration) Up(ctx context.Context, db *gorm.DB) error { func (m *DropPasswordMigration) Down(ctx context.Context, db *gorm.DB) error { dbCtx := db.WithContext(ctx) migrator := dbCtx.Migrator() - dialect := db.Dialector.Name() + dialect := db.Name() if !migrator.HasColumn("users", "password") { var colType string diff --git a/apiserver/internal/migrations/004_drop_app_tokens.go b/apiserver/internal/migrations/004_drop_app_tokens.go index f2e9267..0301c78 100644 --- a/apiserver/internal/migrations/004_drop_app_tokens.go +++ b/apiserver/internal/migrations/004_drop_app_tokens.go @@ -37,7 +37,7 @@ func (m *DropAppTokensMigration) Up(ctx context.Context, db *gorm.DB) error { func (m *DropAppTokensMigration) Down(ctx context.Context, db *gorm.DB) error { dbCtx := db.WithContext(ctx) migrator := dbCtx.Migrator() - dialect := db.Dialector.Name() + dialect := db.Name() if !migrator.HasTable("app_tokens") { switch dialect { diff --git a/apiserver/internal/migrations/005_drop_email.go b/apiserver/internal/migrations/005_drop_email.go index 6c52b5b..5221d04 100644 --- a/apiserver/internal/migrations/005_drop_email.go +++ b/apiserver/internal/migrations/005_drop_email.go @@ -23,7 +23,7 @@ func (m *DropPIIMigration) Name() string { func (m *DropPIIMigration) Up(ctx context.Context, db *gorm.DB) error { dbCtx := db.WithContext(ctx) - dialect := db.Dialector.Name() + dialect := db.Name() switch dialect { case "sqlite": @@ -61,7 +61,7 @@ func (m *DropPIIMigration) Up(ctx context.Context, db *gorm.DB) error { func (m *DropPIIMigration) Down(ctx context.Context, db *gorm.DB) error { dbCtx := db.WithContext(ctx) - dialect := db.Dialector.Name() + dialect := db.Name() switch dialect { case "sqlite": diff --git a/apiserver/internal/migrations/006_account_deletion.go b/apiserver/internal/migrations/006_account_deletion.go index 1ec87a3..6201c24 100644 --- a/apiserver/internal/migrations/006_account_deletion.go +++ b/apiserver/internal/migrations/006_account_deletion.go @@ -23,7 +23,7 @@ func (m *AccountDeletionMigration) Name() string { func (m *AccountDeletionMigration) Up(ctx context.Context, db *gorm.DB) error { dbCtx := db.WithContext(ctx) - dialect := db.Dialector.Name() + dialect := db.Name() switch dialect { case "sqlite": @@ -37,7 +37,7 @@ func (m *AccountDeletionMigration) Up(ctx context.Context, db *gorm.DB) error { func (m *AccountDeletionMigration) Down(ctx context.Context, db *gorm.DB) error { dbCtx := db.WithContext(ctx) - dialect := db.Dialector.Name() + dialect := db.Name() switch dialect { case "sqlite": diff --git a/apiserver/internal/migrations/007_sessions.go b/apiserver/internal/migrations/007_sessions.go index dfd4331..c6b565a 100644 --- a/apiserver/internal/migrations/007_sessions.go +++ b/apiserver/internal/migrations/007_sessions.go @@ -23,7 +23,7 @@ func (m *SessionsMigration) Name() string { func (m *SessionsMigration) Up(ctx context.Context, db *gorm.DB) error { dbCtx := db.WithContext(ctx) - dialect := db.Dialector.Name() + dialect := db.Name() switch dialect { case "sqlite": diff --git a/apiserver/internal/migrations/008_unique_label_names.go b/apiserver/internal/migrations/008_unique_label_names.go index 6546777..37501ac 100644 --- a/apiserver/internal/migrations/008_unique_label_names.go +++ b/apiserver/internal/migrations/008_unique_label_names.go @@ -55,7 +55,7 @@ func (m *UniqueLabelNamesMigration) Up(ctx context.Context, db *gorm.DB) error { )`, } - dialect := db.Dialector.Name() + dialect := db.Name() if dialect == "mysql" { dedup = []string{ `INSERT IGNORE INTO task_labels (task_id, label_id) @@ -100,7 +100,7 @@ func (m *UniqueLabelNamesMigration) Up(ctx context.Context, db *gorm.DB) error { func (m *UniqueLabelNamesMigration) Down(ctx context.Context, db *gorm.DB) error { dbCtx := db.WithContext(ctx) - if db.Dialector.Name() == "mysql" { + if db.Name() == "mysql" { return dbCtx.Exec("DROP INDEX idx_labels_created_by_name ON labels").Error } diff --git a/apiserver/internal/migrations/009_task_histories_task_id_id_index.go b/apiserver/internal/migrations/009_task_histories_task_id_id_index.go index 25607f5..6a2a162 100644 --- a/apiserver/internal/migrations/009_task_histories_task_id_id_index.go +++ b/apiserver/internal/migrations/009_task_histories_task_id_id_index.go @@ -23,7 +23,7 @@ func (m *TaskHistoriesTaskIDIDIndexMigration) Name() string { func (m *TaskHistoriesTaskIDIDIndexMigration) Up(ctx context.Context, db *gorm.DB) error { dbCtx := db.WithContext(ctx) - if db.Dialector.Name() == "mysql" { + if db.Name() == "mysql" { return dbCtx.Exec("CREATE INDEX idx_task_histories_task_id_id ON task_histories(task_id, id)").Error } @@ -33,7 +33,7 @@ func (m *TaskHistoriesTaskIDIDIndexMigration) Up(ctx context.Context, db *gorm.D func (m *TaskHistoriesTaskIDIDIndexMigration) Down(ctx context.Context, db *gorm.DB) error { dbCtx := db.WithContext(ctx) - if db.Dialector.Name() == "mysql" { + if db.Name() == "mysql" { return dbCtx.Exec("DROP INDEX idx_task_histories_task_id_id ON task_histories").Error } diff --git a/apiserver/internal/repos/task/task.go b/apiserver/internal/repos/task/task.go index 61a16e2..9d21a90 100644 --- a/apiserver/internal/repos/task/task.go +++ b/apiserver/internal/repos/task/task.go @@ -249,15 +249,16 @@ func ScheduleNextDueDate(task *models.Task, completedDate time.Time) (*time.Time nextDueDate = baseDate.AddDate(1, 0, 0) } else if freq.Type == "custom" { if freq.On == "interval" { - if freq.Unit == "hours" { + switch freq.Unit { + case "hours": nextDueDate = baseDate.Add(time.Duration(freq.Every) * time.Hour) - } else if freq.Unit == "days" { + case "days": nextDueDate = baseDate.AddDate(0, 0, freq.Every) - } else if freq.Unit == "weeks" { + case "weeks": nextDueDate = baseDate.AddDate(0, 0, 7*freq.Every) - } else if freq.Unit == "months" { + case "months": nextDueDate = baseDate.AddDate(0, freq.Every, 0) - } else if freq.Unit == "years" { + case "years": nextDueDate = baseDate.AddDate(freq.Every, 0, 0) } } else if freq.On == "days_of_the_week" { diff --git a/apiserver/internal/services/notifications/gotify.go b/apiserver/internal/services/notifications/gotify.go index 27b9e65..518bf53 100644 --- a/apiserver/internal/services/notifications/gotify.go +++ b/apiserver/internal/services/notifications/gotify.go @@ -51,7 +51,7 @@ func SendNotificationViaGotify(c context.Context, provider models.NotificationPr if err != nil { return fmt.Errorf("failed to send HTTP request: %s", err.Error()) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("gotify returned non-OK status: %d", resp.StatusCode) diff --git a/apiserver/internal/services/notifications/safehttp_test.go b/apiserver/internal/services/notifications/safehttp_test.go index 166a705..26d7bd7 100644 --- a/apiserver/internal/services/notifications/safehttp_test.go +++ b/apiserver/internal/services/notifications/safehttp_test.go @@ -115,7 +115,7 @@ func TestSafeClientBlocksLoopback(t *testing.T) { resp, err := client.Do(req) if err == nil { - resp.Body.Close() + _ = resp.Body.Close() t.Fatal("expected loopback connection to be blocked") } diff --git a/apiserver/internal/services/notifications/webhook.go b/apiserver/internal/services/notifications/webhook.go index 4f527f5..5400442 100644 --- a/apiserver/internal/services/notifications/webhook.go +++ b/apiserver/internal/services/notifications/webhook.go @@ -44,7 +44,7 @@ func SendNotificationViaWebhook(c context.Context, provider models.NotificationP return fmt.Errorf("error sending HTTP request: %s", err.Error()) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("received non-OK response: %d", resp.StatusCode) diff --git a/apiserver/internal/services/tasks/task.go b/apiserver/internal/services/tasks/task.go index 88dfc7c..9673022 100644 --- a/apiserver/internal/services/tasks/task.go +++ b/apiserver/internal/services/tasks/task.go @@ -515,7 +515,7 @@ func (s *TaskService) CompleteTask(ctx context.Context, userID, taskID int, endR return http.StatusNotFound, gin.H{"error": "Task not found"} } - var completedDate time.Time = time.Now().UTC() + completedDate := time.Now().UTC() var nextDueDate *time.Time = nil if !endRecurrence { diff --git a/apiserver/internal/utils/database/database_test.go b/apiserver/internal/utils/database/database_test.go index 555b54c..4c93bfe 100644 --- a/apiserver/internal/utils/database/database_test.go +++ b/apiserver/internal/utils/database/database_test.go @@ -36,7 +36,7 @@ func (s *DatabaseTestSuite) TearDownTest() { if s.db != nil { sqlDB, err := s.db.DB() if err == nil { - sqlDB.Close() + _ = sqlDB.Close() } } } diff --git a/apiserver/internal/utils/test/database.go b/apiserver/internal/utils/test/database.go index 43d0af9..7dc654b 100644 --- a/apiserver/internal/utils/test/database.go +++ b/apiserver/internal/utils/test/database.go @@ -40,7 +40,7 @@ func (suite *DatabaseTestSuite) TearDownTest() { log.Printf("failed to get database connection: %v", err) return } - db.Close() + _ = db.Close() // Remove the temporary database file created for the test if suite.dbFilePath != "" { diff --git a/apiserver/internal/ws/server_test.go b/apiserver/internal/ws/server_test.go index 4889479..e234cbe 100644 --- a/apiserver/internal/ws/server_test.go +++ b/apiserver/internal/ws/server_test.go @@ -81,7 +81,7 @@ func (s *WSServerTestSuite) TestHandleConnection_Unauthorized() { header := http.Header{} conn, _, err := websocket.DefaultDialer.Dial(url, header) if conn != nil { - conn.Close() + _ = conn.Close() } s.Error(err) s.Equal(0, len(s.server.connections)) @@ -97,7 +97,7 @@ func (s *WSServerTestSuite) TestHandleConnection_Authorized() { s.waitForConnections(1) s.Equal(1, len(s.server.userConnections)) - conn.Close() + _ = conn.Close() s.waitForConnections(0) s.Equal(0, len(s.server.userConnections)) } @@ -115,8 +115,8 @@ func (s *WSServerTestSuite) TestMultipleConnectionsAndCleanup() { s.Equal(1, len(s.server.userConnections)) s.Equal(2, len(s.server.userConnections[1])) - conn1.Close() - conn2.Close() + _ = conn1.Close() + _ = conn2.Close() s.waitForConnections(0) s.Equal(0, len(s.server.userConnections)) } @@ -149,7 +149,7 @@ func (s *WSServerTestSuite) TestPingPongKeepsConnectionAlive() { time.Sleep(3 * s.server.pingPeriod) s.Equal(1, len(s.server.connections)) - conn.Close() + _ = conn.Close() <-done s.waitForConnections(0) } @@ -197,7 +197,7 @@ func (s *WSServerTestSuite) TestHandleMessageRoutesResponse() { conn, _, err := s.dial(ts) s.Require().NoError(err) - defer conn.Close() + defer func() { _ = conn.Close() }() s.waitForConnections(1) @@ -217,7 +217,7 @@ func (s *WSServerTestSuite) TestOversizedMessageClosesConnection() { conn, _, err := s.dial(ts) s.Require().NoError(err) - defer conn.Close() + defer func() { _ = conn.Close() }() s.waitForConnections(1) @@ -248,7 +248,7 @@ func (s *WSServerTestSuite) TestRateLimitRejectsFlood() { conn, _, err := s.dial(ts) s.Require().NoError(err) - defer conn.Close() + defer func() { _ = conn.Close() }() s.waitForConnections(1) From 7bbf077c905b3b2e65c90ce46f042044e543e628 Mon Sep 17 00:00:00 2001 From: Dany Khalife Date: Sun, 21 Jun 2026 07:07:17 -0700 Subject: [PATCH 3/5] Migrate MCP, Android, and Frontend builds to ADO pipelines Add ADO consumer pipelines that extend the shared template, mirroring the existing api-build.yml. MCP maps to a dotnet module (sdk 9.x, Release, test: false), Android to an unsigned debug android module (jdk 21, assembleDebug, sign: false), and Frontend to the new node build type (node 24.x) with opt-in push-only Playwright e2e + report. --- .azuredevops/android-build.yml | 42 +++++++++++++++++++++++++++++++++ .azuredevops/frontend-build.yml | 42 +++++++++++++++++++++++++++++++++ .azuredevops/mcp-build.yml | 42 +++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 .azuredevops/android-build.yml create mode 100644 .azuredevops/frontend-build.yml create mode 100644 .azuredevops/mcp-build.yml diff --git a/.azuredevops/android-build.yml b/.azuredevops/android-build.yml new file mode 100644 index 0000000..5b60dd2 --- /dev/null +++ b/.azuredevops/android-build.yml @@ -0,0 +1,42 @@ +name: $(Date:yyMMdd).00.$(Rev:r) + +trigger: + branches: + include: + - main + paths: + include: + - android/** + +pr: + branches: + include: + - main + paths: + include: + - android/** + +resources: + repositories: + - repository: templates + type: github + name: dkhalife/Pipeline.Templates + endpoint: github + ref: refs/tags/latest + +extends: + template: pipeline.yml@templates + parameters: + stages: + - stage: build + templateContext: + intent: build + jobs: + - job: app + templateContext: + android: + - name: android + jdkVersion: '21' + workingDirectory: android + gradleTasks: assembleDebug + sign: false diff --git a/.azuredevops/frontend-build.yml b/.azuredevops/frontend-build.yml new file mode 100644 index 0000000..fcb6ea6 --- /dev/null +++ b/.azuredevops/frontend-build.yml @@ -0,0 +1,42 @@ +name: $(Date:yyMMdd).00.$(Rev:r) + +trigger: + branches: + include: + - main + paths: + include: + - frontend/** + +pr: + branches: + include: + - main + paths: + include: + - frontend/** + +resources: + repositories: + - repository: templates + type: github + name: dkhalife/Pipeline.Templates + endpoint: github + ref: refs/tags/latest + +extends: + template: pipeline.yml@templates + parameters: + stages: + - stage: build + templateContext: + intent: build + jobs: + - job: app + templateContext: + node: + - name: frontend + nodeVersion: 24.x + workingDirectory: frontend + e2e: true + e2eReportPath: frontend/playwright-report diff --git a/.azuredevops/mcp-build.yml b/.azuredevops/mcp-build.yml new file mode 100644 index 0000000..9ddc2fa --- /dev/null +++ b/.azuredevops/mcp-build.yml @@ -0,0 +1,42 @@ +name: $(Date:yyMMdd).00.$(Rev:r) + +trigger: + branches: + include: + - main + paths: + include: + - mcpserver/** + +pr: + branches: + include: + - main + paths: + include: + - mcpserver/** + +resources: + repositories: + - repository: templates + type: github + name: dkhalife/Pipeline.Templates + endpoint: github + ref: refs/tags/latest + +extends: + template: pipeline.yml@templates + parameters: + stages: + - stage: build + templateContext: + intent: build + jobs: + - job: app + templateContext: + dotnet: + - name: mcp + sdkVersion: '9.x' + projects: mcpserver/**/*.csproj + configuration: Release + test: false From 1e8a330a20c87f7a0b00479ac4ec62fb7b1c2ef8 Mon Sep 17 00:00:00 2001 From: Dany Khalife Date: Sun, 21 Jun 2026 08:36:25 -0700 Subject: [PATCH 4/5] Mark android/gradlew executable The ADO android pipeline invokes ./gradlew directly (no chmod step like the old GitHub workflow had), so the wrapper must carry the executable bit in git. It was committed as 100644. --- android/gradlew | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 android/gradlew diff --git a/android/gradlew b/android/gradlew old mode 100644 new mode 100755 From 076fbf6a51df06e052d32766f1f75bfd28ee7cff Mon Sep 17 00:00:00 2001 From: Dany Khalife Date: Sun, 21 Jun 2026 13:48:04 -0700 Subject: [PATCH 5/5] Run frontend e2e in the Playwright container The hosted agent hangs on `playwright install` (browser download blocks with zero output). Run the job in mcr.microsoft.com/playwright:v1.55.1-noble (matching @playwright/test 1.55.1) where browsers + OS deps are preinstalled, so the install step is an instant no-op. --- .azuredevops/frontend-build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.azuredevops/frontend-build.yml b/.azuredevops/frontend-build.yml index fcb6ea6..17f1d3f 100644 --- a/.azuredevops/frontend-build.yml +++ b/.azuredevops/frontend-build.yml @@ -33,6 +33,7 @@ extends: intent: build jobs: - job: app + container: mcr.microsoft.com/playwright:v1.55.1-noble templateContext: node: - name: frontend