From 58620a63cdc5f867515d84911e9bc54c96a2cb2c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 13 May 2026 17:55:26 +0700 Subject: [PATCH 1/5] feat(ios): SQL Server (MSSQL) driver via FreeTDS with SSLConfiguration --- CHANGELOG.md | 5 + Packages/TableProCore/Package.swift | 13 +- .../TableProMSSQLCore/MSSQLColumnType.swift | 104 ++++ .../MSSQLConnectionOptions.swift | 48 ++ .../TableProMSSQLCore/MSSQLCoreError.swift | 24 + .../MSSQLDatetimeFormatter.swift | 92 ++++ .../TableProMSSQLCore/MSSQLRawResult.swift | 45 ++ .../TableProMSSQLCore/MSSQLRowLimits.swift | 6 + .../MSSQLSchemaQueries.swift | 263 +++++++++ .../MSSQLColumnTypeTests.swift | 58 ++ .../MSSQLDatetimeFormatterTests.swift | 51 ++ .../MSSQLSchemaQueriesTests.swift | 113 ++++ .../TableProMobile.xcodeproj/project.pbxproj | 20 +- .../CBridges/CFreeTDS/CFreeTDS.h | 7 + .../CBridges/CFreeTDS/module.modulemap | 4 + .../Coordinators/ConnectionCoordinator.swift | 6 +- .../Drivers/FreeTDSConnection.swift | 503 ++++++++++++++++++ .../TableProMobile/Drivers/MSSQLDriver.swift | 291 ++++++++++ .../Helpers/DatabaseType+Mobile.swift | 5 +- .../TableProMobile/Helpers/SQLBuilder.swift | 47 +- .../Platform/IOSDriverFactory.swift | 4 +- .../ViewModels/DataBrowserViewModel.swift | 16 +- .../Views/DataBrowserView.swift | 2 +- .../Drivers/MSSQLDriverTests.swift | 128 +++++ scripts/build-freetds.sh | 204 +++++-- 25 files changed, 1983 insertions(+), 76 deletions(-) create mode 100644 Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLColumnType.swift create mode 100644 Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift create mode 100644 Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLCoreError.swift create mode 100644 Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLDatetimeFormatter.swift create mode 100644 Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLRawResult.swift create mode 100644 Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLRowLimits.swift create mode 100644 Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift create mode 100644 Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLColumnTypeTests.swift create mode 100644 Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLDatetimeFormatterTests.swift create mode 100644 Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift create mode 100644 TableProMobile/TableProMobile/CBridges/CFreeTDS/CFreeTDS.h create mode 100644 TableProMobile/TableProMobile/CBridges/CFreeTDS/module.modulemap create mode 100644 TableProMobile/TableProMobile/Drivers/FreeTDSConnection.swift create mode 100644 TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift create mode 100644 TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 39dbc2f84..5ea14f328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- iOS: SQL Server (MSSQL) connections via FreeTDS over TDS 7.4. Uses the shared `SSLConfiguration` model from connection settings. Supports connect, query, streaming results, schema browsing (tables, columns, indexes, foreign keys), database and schema switching, and explicit transactions. +- iOS: data browser, search, filter, and pagination now render correct SQL Server syntax (bracket-quoted identifiers, `OFFSET ... ROWS FETCH NEXT ... ROWS ONLY` pagination, `SELECT TOP 1` for cell value fetch). + ## [0.41.0] - 2026-05-13 ### Added diff --git a/Packages/TableProCore/Package.swift b/Packages/TableProCore/Package.swift index 302781d3b..ac0d7ae13 100644 --- a/Packages/TableProCore/Package.swift +++ b/Packages/TableProCore/Package.swift @@ -14,7 +14,8 @@ let package = Package( .library(name: "TableProDatabase", targets: ["TableProDatabase"]), .library(name: "TableProQuery", targets: ["TableProQuery"]), .library(name: "TableProSync", targets: ["TableProSync"]), - .library(name: "TableProAnalytics", targets: ["TableProAnalytics"]) + .library(name: "TableProAnalytics", targets: ["TableProAnalytics"]), + .library(name: "TableProMSSQLCore", targets: ["TableProMSSQLCore"]) ], targets: [ .target( @@ -47,6 +48,11 @@ let package = Package( dependencies: [], path: "Sources/TableProAnalytics" ), + .target( + name: "TableProMSSQLCore", + dependencies: [], + path: "Sources/TableProMSSQLCore" + ), .testTarget( name: "TableProModelsTests", dependencies: ["TableProModels", "TableProPluginKit"], @@ -66,6 +72,11 @@ let package = Package( name: "TableProAnalyticsTests", dependencies: ["TableProAnalytics"], path: "Tests/TableProAnalyticsTests" + ), + .testTarget( + name: "TableProMSSQLCoreTests", + dependencies: ["TableProMSSQLCore"], + path: "Tests/TableProMSSQLCoreTests" ) ] ) diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLColumnType.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLColumnType.swift new file mode 100644 index 000000000..1f49010fa --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLColumnType.swift @@ -0,0 +1,104 @@ +import Foundation + +public enum MSSQLColumnType: Sendable, Equatable { + case char + case varchar + case text + case nchar + case nvarchar + case ntext + case tinyInt + case smallInt + case int + case bigInt + case float + case real + case decimal + case money + case smallMoney + case bit + case binary + case varbinary + case image + case dateTime + case smallDateTime + case dateTimeN + case date + case time + case dateTime2 + case dateTimeOffset + case uniqueIdentifier + case xml + case sqlVariant + case unknown(Int32) + + public var canonicalName: String { + switch self { + case .char: return "char" + case .varchar: return "varchar" + case .text: return "text" + case .nchar: return "nchar" + case .nvarchar: return "nvarchar" + case .ntext: return "ntext" + case .tinyInt: return "tinyint" + case .smallInt: return "smallint" + case .int: return "int" + case .bigInt: return "bigint" + case .float: return "float" + case .real: return "real" + case .decimal: return "decimal" + case .money: return "money" + case .smallMoney: return "smallmoney" + case .bit: return "bit" + case .binary: return "binary" + case .varbinary: return "varbinary" + case .image: return "image" + case .dateTime, .dateTimeN: return "datetime" + case .smallDateTime: return "smalldatetime" + case .date: return "date" + case .time: return "time" + case .dateTime2: return "datetime2" + case .dateTimeOffset: return "datetimeoffset" + case .uniqueIdentifier: return "uniqueidentifier" + case .xml: return "xml" + case .sqlVariant: return "sql_variant" + case .unknown: return "unknown" + } + } + + public var isDateOrTime: Bool { + switch self { + case .dateTime, .smallDateTime, .dateTimeN, .date, .time, .dateTime2, .dateTimeOffset: + return true + default: + return false + } + } + + public var isBinary: Bool { + switch self { + case .binary, .varbinary, .image: + return true + default: + return false + } + } + + public var isUnicodeString: Bool { + switch self { + case .nchar, .nvarchar, .ntext: + return true + default: + return false + } + } + + public var isNarrowString: Bool { + switch self { + case .char, .varchar, .text: + return true + default: + return false + } + } +} diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift new file mode 100644 index 000000000..64e4c52a7 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift @@ -0,0 +1,48 @@ +import Foundation + +public struct MSSQLConnectionOptions: Sendable, Equatable { + public var host: String + public var port: Int + public var user: String + public var password: String + public var database: String + public var schema: String + public var encryptionFlag: String + public var applicationName: String + + public static let defaultPort = 1433 + public static let defaultSchema = "dbo" + public static let defaultApplicationName = "TablePro" + public static let defaultEncryptionFlag = "off" + + public init( + host: String, + port: Int = MSSQLConnectionOptions.defaultPort, + user: String, + password: String, + database: String, + schema: String = MSSQLConnectionOptions.defaultSchema, + encryptionFlag: String = MSSQLConnectionOptions.defaultEncryptionFlag, + applicationName: String = MSSQLConnectionOptions.defaultApplicationName + ) { + self.host = host + self.port = port + self.user = user + self.password = password + self.database = database + self.schema = schema + self.encryptionFlag = encryptionFlag + self.applicationName = applicationName + } +} + +public extension MSSQLConnectionOptions { + enum AdditionalFieldKey { + public static let schema = "mssqlSchema" + } + + static func schema(from additionalFields: [String: String]) -> String { + let raw = additionalFields[AdditionalFieldKey.schema] ?? "" + return raw.isEmpty ? defaultSchema : raw + } +} diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLCoreError.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLCoreError.swift new file mode 100644 index 000000000..8343aa526 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLCoreError.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum MSSQLCoreError: LocalizedError, Sendable { + case connectionFailed(String) + case notConnected + case queryFailed(String) + case cancelled + case tlsHandshakeFailed(String) + + public var errorDescription: String? { + switch self { + case .connectionFailed(let detail): + return String(format: String(localized: "Connection failed: %@"), detail) + case .notConnected: + return String(localized: "Not connected to SQL Server") + case .queryFailed(let detail): + return String(format: String(localized: "Query failed: %@"), detail) + case .cancelled: + return String(localized: "Query was cancelled") + case .tlsHandshakeFailed(let detail): + return String(format: String(localized: "TLS handshake failed: %@"), detail) + } + } +} diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLDatetimeFormatter.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLDatetimeFormatter.swift new file mode 100644 index 000000000..fab4d5993 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLDatetimeFormatter.swift @@ -0,0 +1,92 @@ +import Foundation + +public enum MSSQLDatetimeFormatter { + public static func reformat(_ raw: String, type: MSSQLColumnType) -> String? { + guard type.isDateOrTime else { return nil } + return parse(raw) + } + + public static func parse(_ raw: String) -> String? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if isAlreadyISO(trimmed) { + return trimmed + } + return parseLegacyAMPM(trimmed) + } + + public static func isAlreadyISO(_ s: String) -> Bool { + let chars = Array(s) + guard chars.count >= 10 else { return false } + return chars[0].isASCIIDigit && chars[1].isASCIIDigit + && chars[2].isASCIIDigit && chars[3].isASCIIDigit + && chars[4] == "-" + && chars[5].isASCIIDigit && chars[6].isASCIIDigit + && chars[7] == "-" + && chars[8].isASCIIDigit && chars[9].isASCIIDigit + } + + private static func parseLegacyAMPM(_ raw: String) -> String? { + let scanner = Scanner(string: raw) + scanner.charactersToBeSkipped = nil + _ = scanner.scanCharacters(from: .whitespaces) + + guard let monthToken = scanner.scanCharacters(from: .letters), + monthToken.count >= 3, + let month = monthNamesByPrefix[String(monthToken.prefix(3))] + else { return nil } + + _ = scanner.scanCharacters(from: .whitespaces) + guard let day = scanner.scanInt(), (1...31).contains(day) else { return nil } + _ = scanner.scanCharacters(from: .whitespaces) + guard let year = scanner.scanInt(), (1...9999).contains(year) else { return nil } + _ = scanner.scanCharacters(from: .whitespaces) + guard var hour = scanner.scanInt() else { return nil } + + var minute = 0 + var second = 0 + var fractional = "" + + if scanner.scanString(":") != nil { + guard let m = scanner.scanInt(), (0...59).contains(m) else { return nil } + minute = m + } + if scanner.scanString(":") != nil { + guard let s = scanner.scanInt(), (0...59).contains(s) else { return nil } + second = s + } + if scanner.scanString(":") != nil || scanner.scanString(".") != nil { + fractional = scanner.scanCharacters(from: .decimalDigits) ?? "" + } + + _ = scanner.scanCharacters(from: .whitespaces) + let ampm = scanner.scanCharacters(from: .letters)?.uppercased() + + if let ampm { + guard ampm == "AM" || ampm == "PM" else { return nil } + guard (1...12).contains(hour) else { return nil } + if ampm == "PM", hour < 12 { + hour += 12 + } else if ampm == "AM", hour == 12 { + hour = 0 + } + } else { + guard (0...23).contains(hour) else { return nil } + } + + var iso = String(format: "%04d-%02d-%02d %02d:%02d:%02d", year, month, day, hour, minute, second) + if !fractional.isEmpty { + iso += "." + fractional + } + return iso + } + + private static let monthNamesByPrefix: [String: Int] = [ + "Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "May": 5, "Jun": 6, + "Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12 + ] +} + +private extension Character { + var isASCIIDigit: Bool { isASCII && isNumber } +} diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLRawResult.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLRawResult.swift new file mode 100644 index 000000000..f85f2edd5 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLRawResult.swift @@ -0,0 +1,45 @@ +import Foundation + +public struct MSSQLColumnDescriptor: Sendable, Equatable { + public let name: String + public let type: MSSQLColumnType + + public init(name: String, type: MSSQLColumnType) { + self.name = name + self.type = type + } +} + +public enum MSSQLRawCell: Sendable, Equatable { + case null + case string(String) + case bytes(Data) + + public var stringValue: String? { + switch self { + case .null: return nil + case .string(let s): return s + case .bytes(let d): return String(data: d, encoding: .utf8) + } + } +} + +public struct MSSQLRawResult: Sendable { + public let columns: [MSSQLColumnDescriptor] + public let rows: [[MSSQLRawCell]] + public let affectedRows: Int + public let isTruncated: Bool + + public init(columns: [MSSQLColumnDescriptor], rows: [[MSSQLRawCell]], affectedRows: Int, isTruncated: Bool) { + self.columns = columns + self.rows = rows + self.affectedRows = affectedRows + self.isTruncated = isTruncated + } +} + +public enum MSSQLStreamElement: Sendable { + case header(columns: [MSSQLColumnDescriptor]) + case rows([[MSSQLRawCell]]) + case affectedRows(Int) +} diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLRowLimits.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLRowLimits.swift new file mode 100644 index 000000000..b91195bda --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLRowLimits.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum MSSQLRowLimits { + public static let emergencyMax = 5_000_000 + public static let streamBatchSize = 5_000 +} diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift new file mode 100644 index 000000000..a0a56b020 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLSchemaQueries.swift @@ -0,0 +1,263 @@ +import Foundation + +public enum MSSQLSchemaQueries { + public static func escape(_ value: String) -> String { + value.replacingOccurrences(of: "'", with: "''") + } + + public static func escapeBracket(_ value: String) -> String { + value.replacingOccurrences(of: "]", with: "]]") + } + + public static func bracketed(schema: String, table: String) -> String { + "[\(escapeBracket(schema))].[\(escapeBracket(table))]" + } + + public static let currentSchema = "SELECT SCHEMA_NAME()" + public static let serverVersion = "SELECT @@VERSION" + public static let beginTransaction = "BEGIN TRANSACTION" + public static let commitTransaction = "COMMIT TRANSACTION" + public static let rollbackTransaction = "ROLLBACK TRANSACTION" + public static let ping = "SELECT 1" + + public static let databases = "SELECT name FROM sys.databases ORDER BY name" + + public static let schemas = """ + SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA + WHERE SCHEMA_NAME NOT IN ( + 'information_schema','sys','db_owner','db_accessadmin', + 'db_securityadmin','db_ddladmin','db_backupoperator', + 'db_datareader','db_datawriter','db_denydatareader', + 'db_denydatawriter','guest' + ) + ORDER BY SCHEMA_NAME + """ + + public static func tables(schema: String) -> String { + let s = escape(schema) + return """ + SELECT t.TABLE_NAME, t.TABLE_TYPE + FROM INFORMATION_SCHEMA.TABLES t + WHERE t.TABLE_SCHEMA = '\(s)' + AND t.TABLE_TYPE IN ('BASE TABLE', 'VIEW') + ORDER BY t.TABLE_NAME + """ + } + + public static func columns(schema: String, table: String) -> String { + let s = escape(schema) + let t = escape(table) + return """ + SELECT + c.COLUMN_NAME, + c.DATA_TYPE, + c.CHARACTER_MAXIMUM_LENGTH, + c.NUMERIC_PRECISION, + c.NUMERIC_SCALE, + c.IS_NULLABLE, + c.COLUMN_DEFAULT, + COLUMNPROPERTY(OBJECT_ID(c.TABLE_SCHEMA + '.' + c.TABLE_NAME), c.COLUMN_NAME, 'IsIdentity') AS IS_IDENTITY, + CASE WHEN pk.COLUMN_NAME IS NOT NULL THEN 1 ELSE 0 END AS IS_PK + FROM INFORMATION_SCHEMA.COLUMNS c + LEFT JOIN ( + SELECT kcu.COLUMN_NAME + FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc + JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu + ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA + WHERE tc.CONSTRAINT_TYPE = 'PRIMARY KEY' + AND tc.TABLE_SCHEMA = '\(s)' + AND tc.TABLE_NAME = '\(t)' + ) pk ON c.COLUMN_NAME = pk.COLUMN_NAME + WHERE c.TABLE_NAME = '\(t)' + AND c.TABLE_SCHEMA = '\(s)' + ORDER BY c.ORDINAL_POSITION + """ + } + + public static func indexes(schema: String, table: String) -> String { + let object = bracketed(schema: schema, table: table) + return """ + SELECT i.name, i.is_unique, i.is_primary_key, c.name AS column_name + FROM sys.indexes i + JOIN sys.index_columns ic + ON i.object_id = ic.object_id AND i.index_id = ic.index_id + JOIN sys.columns c + ON ic.object_id = c.object_id AND ic.column_id = c.column_id + WHERE i.object_id = OBJECT_ID('\(object)') + AND i.name IS NOT NULL + ORDER BY i.index_id, ic.key_ordinal + """ + } + + public static func foreignKeys(schema: String, table: String) -> String { + let s = escape(schema) + let t = escape(table) + return """ + SELECT + fk.name AS constraint_name, + cp.name AS column_name, + tr.name AS ref_table, + cr.name AS ref_column + FROM sys.foreign_keys fk + JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id + JOIN sys.tables tp ON fkc.parent_object_id = tp.object_id + JOIN sys.schemas s ON tp.schema_id = s.schema_id + JOIN sys.columns cp + ON fkc.parent_object_id = cp.object_id AND fkc.parent_column_id = cp.column_id + JOIN sys.tables tr ON fkc.referenced_object_id = tr.object_id + JOIN sys.columns cr + ON fkc.referenced_object_id = cr.object_id AND fkc.referenced_column_id = cr.column_id + WHERE tp.name = '\(t)' AND s.name = '\(s)' + ORDER BY fk.name + """ + } +} + +public struct MSSQLTableRow: Sendable, Equatable { + public let name: String + public let isView: Bool + + public init(name: String, isView: Bool) { + self.name = name + self.isView = isView + } +} + +public struct MSSQLColumnRow: Sendable, Equatable { + public let name: String + public let dataType: String + public let characterMaxLength: Int? + public let numericPrecision: Int? + public let numericScale: Int? + public let isNullable: Bool + public let defaultValue: String? + public let isIdentity: Bool + public let isPrimaryKey: Bool + + public init( + name: String, + dataType: String, + characterMaxLength: Int?, + numericPrecision: Int?, + numericScale: Int?, + isNullable: Bool, + defaultValue: String?, + isIdentity: Bool, + isPrimaryKey: Bool + ) { + self.name = name + self.dataType = dataType + self.characterMaxLength = characterMaxLength + self.numericPrecision = numericPrecision + self.numericScale = numericScale + self.isNullable = isNullable + self.defaultValue = defaultValue + self.isIdentity = isIdentity + self.isPrimaryKey = isPrimaryKey + } + + public var displayType: String { + let base = dataType.lowercased() + let fixedSize: Set = [ + "int", "bigint", "smallint", "tinyint", "bit", + "money", "smallmoney", "float", "real", + "datetime", "datetime2", "smalldatetime", "date", "time", + "uniqueidentifier", "text", "ntext", "image", "xml", + "timestamp", "rowversion" + ] + if fixedSize.contains(base) { + return base + } + if let len = characterMaxLength { + return len < 0 ? "\(base)(max)" : "\(base)(\(len))" + } + if let p = numericPrecision, let s = numericScale { + return "\(base)(\(p),\(s))" + } + return base + } +} + +public struct MSSQLIndexRow: Sendable, Equatable { + public let name: String + public let isUnique: Bool + public let isPrimary: Bool + public let columnName: String + + public init(name: String, isUnique: Bool, isPrimary: Bool, columnName: String) { + self.name = name + self.isUnique = isUnique + self.isPrimary = isPrimary + self.columnName = columnName + } +} + +public struct MSSQLForeignKeyRow: Sendable, Equatable { + public let constraintName: String + public let columnName: String + public let referencedTable: String + public let referencedColumn: String + + public init(constraintName: String, columnName: String, referencedTable: String, referencedColumn: String) { + self.constraintName = constraintName + self.columnName = columnName + self.referencedTable = referencedTable + self.referencedColumn = referencedColumn + } +} + +public extension MSSQLSchemaQueries { + static func parseTableRow(_ row: [String?]) -> MSSQLTableRow? { + guard let name = row[safe: 0] ?? nil else { return nil } + let typeRaw = (row[safe: 1] ?? nil) ?? "BASE TABLE" + return MSSQLTableRow(name: name, isView: typeRaw == "VIEW") + } + + static func parseColumnRow(_ row: [String?]) -> MSSQLColumnRow? { + guard let name = row[safe: 0] ?? nil else { return nil } + return MSSQLColumnRow( + name: name, + dataType: (row[safe: 1] ?? nil) ?? "nvarchar", + characterMaxLength: (row[safe: 2] ?? nil).flatMap { Int($0) }, + numericPrecision: (row[safe: 3] ?? nil).flatMap { Int($0) }, + numericScale: (row[safe: 4] ?? nil).flatMap { Int($0) }, + isNullable: (row[safe: 5] ?? nil) == "YES", + defaultValue: row[safe: 6] ?? nil, + isIdentity: (row[safe: 7] ?? nil) == "1", + isPrimaryKey: (row[safe: 8] ?? nil) == "1" + ) + } + + static func parseIndexRow(_ row: [String?]) -> MSSQLIndexRow? { + guard let name = row[safe: 0] ?? nil, + let column = row[safe: 3] ?? nil + else { return nil } + return MSSQLIndexRow( + name: name, + isUnique: (row[safe: 1] ?? nil) == "1", + isPrimary: (row[safe: 2] ?? nil) == "1", + columnName: column + ) + } + + static func parseForeignKeyRow(_ row: [String?]) -> MSSQLForeignKeyRow? { + guard let name = row[safe: 0] ?? nil, + let column = row[safe: 1] ?? nil, + let refTable = row[safe: 2] ?? nil, + let refColumn = row[safe: 3] ?? nil + else { return nil } + return MSSQLForeignKeyRow( + constraintName: name, + columnName: column, + referencedTable: refTable, + referencedColumn: refColumn + ) + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLColumnTypeTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLColumnTypeTests.swift new file mode 100644 index 000000000..ebd7f12da --- /dev/null +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLColumnTypeTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import TableProMSSQLCore + +final class MSSQLColumnTypeTests: XCTestCase { + func testIsBinaryCoversBinaryFamily() { + XCTAssertTrue(MSSQLColumnType.binary.isBinary) + XCTAssertTrue(MSSQLColumnType.varbinary.isBinary) + XCTAssertTrue(MSSQLColumnType.image.isBinary) + XCTAssertFalse(MSSQLColumnType.varchar.isBinary) + XCTAssertFalse(MSSQLColumnType.int.isBinary) + } + + func testIsDateOrTimeCoversAllDateTimeVariants() { + let dateTypes: [MSSQLColumnType] = [ + .dateTime, .smallDateTime, .dateTimeN, + .date, .time, .dateTime2, .dateTimeOffset + ] + for type in dateTypes { + XCTAssertTrue(type.isDateOrTime, "\(type.canonicalName) should be date/time") + } + XCTAssertFalse(MSSQLColumnType.int.isDateOrTime) + XCTAssertFalse(MSSQLColumnType.varchar.isDateOrTime) + } + + func testIsUnicodeStringOnlyForNTypes() { + XCTAssertTrue(MSSQLColumnType.nchar.isUnicodeString) + XCTAssertTrue(MSSQLColumnType.nvarchar.isUnicodeString) + XCTAssertTrue(MSSQLColumnType.ntext.isUnicodeString) + XCTAssertFalse(MSSQLColumnType.char.isUnicodeString) + XCTAssertFalse(MSSQLColumnType.varchar.isUnicodeString) + } + + func testIsNarrowStringOnlyForNonUnicodeStrings() { + XCTAssertTrue(MSSQLColumnType.char.isNarrowString) + XCTAssertTrue(MSSQLColumnType.varchar.isNarrowString) + XCTAssertTrue(MSSQLColumnType.text.isNarrowString) + XCTAssertFalse(MSSQLColumnType.nvarchar.isNarrowString) + XCTAssertFalse(MSSQLColumnType.int.isNarrowString) + } + + func testCanonicalNameForDateTimeFamily() { + XCTAssertEqual(MSSQLColumnType.dateTime.canonicalName, "datetime") + XCTAssertEqual(MSSQLColumnType.dateTimeN.canonicalName, "datetime") + XCTAssertEqual(MSSQLColumnType.smallDateTime.canonicalName, "smalldatetime") + XCTAssertEqual(MSSQLColumnType.dateTime2.canonicalName, "datetime2") + XCTAssertEqual(MSSQLColumnType.dateTimeOffset.canonicalName, "datetimeoffset") + } + + func testUnknownTypePreservesToken() { + let unknown = MSSQLColumnType.unknown(99) + XCTAssertEqual(unknown.canonicalName, "unknown") + if case .unknown(let token) = unknown { + XCTAssertEqual(token, 99) + } else { + XCTFail("expected .unknown case") + } + } +} diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLDatetimeFormatterTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLDatetimeFormatterTests.swift new file mode 100644 index 000000000..ea6ffc336 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLDatetimeFormatterTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import TableProMSSQLCore + +final class MSSQLDatetimeFormatterTests: XCTestCase { + func testDatetimeIsReformatted() { + let result = MSSQLDatetimeFormatter.reformat("Jan 15 2024 10:30:00:123AM", type: .dateTime) + XCTAssertEqual(result, "2024-01-15 10:30:00.123") + } + + func testPMHoursAreAdjusted() { + let result = MSSQLDatetimeFormatter.reformat("Mar 5 2024 2:45:30PM", type: .dateTime) + XCTAssertEqual(result, "2024-03-05 14:45:30") + } + + func testNoonHandledCorrectly() { + XCTAssertEqual(MSSQLDatetimeFormatter.parse("Jun 1 2024 12:00:00PM"), "2024-06-01 12:00:00") + } + + func testMidnightHandledCorrectly() { + XCTAssertEqual(MSSQLDatetimeFormatter.parse("Jun 1 2024 12:00:00AM"), "2024-06-01 00:00:00") + } + + func testAlreadyISOPassesThrough() { + let raw = "2024-01-15 10:30:00.123" + XCTAssertEqual(MSSQLDatetimeFormatter.parse(raw), raw) + } + + func testReformatReturnsNilForNonDatetimeType() { + XCTAssertNil(MSSQLDatetimeFormatter.reformat("Jan 15 2024 10:30:00AM", type: .int)) + XCTAssertNil(MSSQLDatetimeFormatter.reformat("Jan 15 2024 10:30:00AM", type: .nvarchar)) + } + + func testEmptyInputReturnsNil() { + XCTAssertNil(MSSQLDatetimeFormatter.parse("")) + XCTAssertNil(MSSQLDatetimeFormatter.parse(" ")) + } + + func testInvalidMonthReturnsNil() { + XCTAssertNil(MSSQLDatetimeFormatter.parse("Xyz 1 2024 10:00AM")) + } + + func testFractionalSecondsPreserved() { + let result = MSSQLDatetimeFormatter.parse("Jan 1 2024 1:00:00:1234567AM") + XCTAssertEqual(result, "2024-01-01 01:00:00.1234567") + } + + func testDate2025Handled() { + XCTAssertEqual(MSSQLDatetimeFormatter.reformat("Dec 31 2025 11:59:59:999PM", type: .dateTime2), + "2025-12-31 23:59:59.999") + } +} diff --git a/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift new file mode 100644 index 000000000..2b488d5d5 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProMSSQLCoreTests/MSSQLSchemaQueriesTests.swift @@ -0,0 +1,113 @@ +import XCTest +@testable import TableProMSSQLCore + +final class MSSQLSchemaQueriesTests: XCTestCase { + func testEscapeHandlesSingleQuote() { + XCTAssertEqual(MSSQLSchemaQueries.escape("O'Brien"), "O''Brien") + XCTAssertEqual(MSSQLSchemaQueries.escape("plain"), "plain") + } + + func testEscapeBracketHandlesClosingBracket() { + XCTAssertEqual(MSSQLSchemaQueries.escapeBracket("weird]name"), "weird]]name") + } + + func testBracketedComposesIdentifier() { + XCTAssertEqual(MSSQLSchemaQueries.bracketed(schema: "dbo", table: "Users"), "[dbo].[Users]") + XCTAssertEqual(MSSQLSchemaQueries.bracketed(schema: "weird]", table: "x"), "[weird]]].[x]") + } + + func testTablesQueryEscapesSchema() { + let sql = MSSQLSchemaQueries.tables(schema: "O'Brien") + XCTAssertTrue(sql.contains("'O''Brien'")) + XCTAssertTrue(sql.contains("INFORMATION_SCHEMA.TABLES")) + XCTAssertTrue(sql.contains("'BASE TABLE'")) + XCTAssertTrue(sql.contains("'VIEW'")) + } + + func testColumnsQueryIncludesIdentityAndPrimaryKey() { + let sql = MSSQLSchemaQueries.columns(schema: "dbo", table: "Users") + XCTAssertTrue(sql.contains("IsIdentity")) + XCTAssertTrue(sql.contains("PRIMARY KEY")) + XCTAssertTrue(sql.contains("'Users'")) + XCTAssertTrue(sql.contains("'dbo'")) + } + + func testIndexesQueryUsesBracketedIdentifier() { + let sql = MSSQLSchemaQueries.indexes(schema: "dbo", table: "Users") + XCTAssertTrue(sql.contains("OBJECT_ID('[dbo].[Users]')")) + XCTAssertTrue(sql.contains("sys.indexes")) + } + + func testForeignKeysQueryFiltersByTableAndSchema() { + let sql = MSSQLSchemaQueries.foreignKeys(schema: "dbo", table: "Orders") + XCTAssertTrue(sql.contains("sys.foreign_keys")) + XCTAssertTrue(sql.contains("'Orders'")) + XCTAssertTrue(sql.contains("'dbo'")) + } + + func testParseTableRowDetectsView() { + let row: [String?] = ["v_active_users", "VIEW"] + XCTAssertEqual(MSSQLSchemaQueries.parseTableRow(row), MSSQLTableRow(name: "v_active_users", isView: true)) + } + + func testParseTableRowDefaultsToTable() { + let row: [String?] = ["users", "BASE TABLE"] + XCTAssertEqual(MSSQLSchemaQueries.parseTableRow(row), MSSQLTableRow(name: "users", isView: false)) + } + + func testParseTableRowRejectsMissingName() { + XCTAssertNil(MSSQLSchemaQueries.parseTableRow([nil, "BASE TABLE"])) + } + + func testParseColumnRowExtractsAllFields() { + let row: [String?] = ["id", "int", nil, "10", "0", "NO", nil, "1", "1"] + let parsed = MSSQLSchemaQueries.parseColumnRow(row) + XCTAssertEqual(parsed?.name, "id") + XCTAssertEqual(parsed?.dataType, "int") + XCTAssertEqual(parsed?.isNullable, false) + XCTAssertEqual(parsed?.isIdentity, true) + XCTAssertEqual(parsed?.isPrimaryKey, true) + } + + func testColumnDisplayTypeForFixedSizeOmitsLength() { + let row: [String?] = ["id", "int", "4", nil, nil, "NO", nil, "0", "0"] + XCTAssertEqual(MSSQLSchemaQueries.parseColumnRow(row)?.displayType, "int") + } + + func testColumnDisplayTypeForVarcharIncludesLength() { + let row: [String?] = ["name", "nvarchar", "100", nil, nil, "YES", nil, "0", "0"] + XCTAssertEqual(MSSQLSchemaQueries.parseColumnRow(row)?.displayType, "nvarchar(100)") + } + + func testColumnDisplayTypeForMaxLengthRendersMax() { + let row: [String?] = ["body", "varchar", "-1", nil, nil, "YES", nil, "0", "0"] + XCTAssertEqual(MSSQLSchemaQueries.parseColumnRow(row)?.displayType, "varchar(max)") + } + + func testColumnDisplayTypeForDecimalIncludesPrecisionScale() { + let row: [String?] = ["amount", "decimal", nil, "18", "2", "NO", nil, "0", "0"] + XCTAssertEqual(MSSQLSchemaQueries.parseColumnRow(row)?.displayType, "decimal(18,2)") + } + + func testParseIndexRowExtractsFlags() { + let row: [String?] = ["IX_users_email", "1", "0", "email"] + let parsed = MSSQLSchemaQueries.parseIndexRow(row) + XCTAssertEqual(parsed?.name, "IX_users_email") + XCTAssertEqual(parsed?.isUnique, true) + XCTAssertEqual(parsed?.isPrimary, false) + XCTAssertEqual(parsed?.columnName, "email") + } + + func testParseIndexRowRejectsMissingColumn() { + XCTAssertNil(MSSQLSchemaQueries.parseIndexRow(["IX_x", "0", "0", nil])) + } + + func testParseForeignKeyRowExtractsAll() { + let row: [String?] = ["FK_orders_users", "user_id", "users", "id"] + let parsed = MSSQLSchemaQueries.parseForeignKeyRow(row) + XCTAssertEqual(parsed?.constraintName, "FK_orders_users") + XCTAssertEqual(parsed?.columnName, "user_id") + XCTAssertEqual(parsed?.referencedTable, "users") + XCTAssertEqual(parsed?.referencedColumn, "id") + } +} diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj index c6ce415b8..f9746ad8e 100644 --- a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj +++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj @@ -24,6 +24,8 @@ 5AB9F3EB2F7C1D03001F3337 /* TableProModels in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3EA2F7C1D03001F3337 /* TableProModels */; }; 5AB9F3ED2F7C1D03001F3337 /* TableProPluginKit in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3EC2F7C1D03001F3337 /* TableProPluginKit */; }; 5AB9F3EF2F7C1D03001F3337 /* TableProQuery in Frameworks */ = {isa = PBXBuildFile; productRef = 5AB9F3EE2F7C1D03001F3337 /* TableProQuery */; }; + 5AD1F1B62FB4455700296783 /* TableProMSSQLCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5AD1F1B52FB4455700296783 /* TableProMSSQLCore */; }; + 5AD1F1B82FB4456900296783 /* FreeTDS.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5AD1F1B72FB4456900296783 /* FreeTDS.xcframework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -526,6 +528,7 @@ 5AA313532F7EC188008EBA97 /* LibSSH2.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = LibSSH2.xcframework; path = ../Libs/ios/LibSSH2.xcframework; sourceTree = ""; }; 5AB9F3D92F7C1C12001F3337 /* TableProMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TableProMobile.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5AC8A8F82FAFC99F005DE2A3 /* TableProMobileTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProMobileTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 5AD1F1B72FB4456900296783 /* FreeTDS.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = FreeTDS.xcframework; path = ../Libs/ios/FreeTDS.xcframework; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -604,6 +607,8 @@ 5AA3133E2F7EA5B4008EBA97 /* OpenSSL-Crypto.xcframework in Frameworks */, 5AA313442F7EA5B4008EBA97 /* OpenSSL-SSL.xcframework in Frameworks */, 5AB9F3ED2F7C1D03001F3337 /* TableProPluginKit in Frameworks */, + 5AD1F1B82FB4456900296783 /* FreeTDS.xcframework in Frameworks */, + 5AD1F1B62FB4455700296783 /* TableProMSSQLCore in Frameworks */, 5A87EEED2F7F893000D028D0 /* TableProSync in Frameworks */, 5AB9F3EB2F7C1D03001F3337 /* TableProModels in Frameworks */, ); @@ -1628,6 +1633,7 @@ 5AA313332F7EA5B4008EBA97 /* Frameworks */ = { isa = PBXGroup; children = ( + 5AD1F1B72FB4456900296783 /* FreeTDS.xcframework */, 5A87EEEB2F7F891F00D028D0 /* TableProSync */, 5A87EEE42F7F88F200D028D0 /* TablePro */, 5AA313532F7EC188008EBA97 /* LibSSH2.xcframework */, @@ -1716,6 +1722,7 @@ 5AB9F3EE2F7C1D03001F3337 /* TableProQuery */, 5A87EEEC2F7F893000D028D0 /* TableProSync */, 5A87EEED2F7F893000D028D1 /* TableProAnalytics */, + 5AD1F1B52FB4455700296783 /* TableProMSSQLCore */, ); productName = TableProMobile; productReference = 5AB9F3D92F7C1C12001F3337 /* TableProMobile.app */; @@ -2075,7 +2082,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2"; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2 $(SRCROOT)/TableProMobile/CBridges/CFreeTDS"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2117,7 +2124,7 @@ SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2"; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2 $(SRCROOT)/TableProMobile/CBridges/CFreeTDS"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2139,7 +2146,7 @@ STRING_CATALOG_GENERATE_SYMBOLS = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2"; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2 $(SRCROOT)/TableProMobile/CBridges/CFreeTDS"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2162,7 +2169,7 @@ STRING_CATALOG_GENERATE_SYMBOLS = NO; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2"; + SWIFT_INCLUDE_PATHS = "$(SRCROOT)/TableProMobile/CBridges/CMariaDB $(SRCROOT)/TableProMobile/CBridges/CLibPQ $(SRCROOT)/TableProMobile/CBridges/CRedis $(SRCROOT)/TableProMobile/CBridges/CLibSSH2 $(SRCROOT)/TableProMobile/CBridges/CFreeTDS"; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -2249,6 +2256,11 @@ package = 5AB9F3E72F7C1D03001F3337 /* XCLocalSwiftPackageReference "../Packages/TableProCore" */; productName = TableProQuery; }; + 5AD1F1B52FB4455700296783 /* TableProMSSQLCore */ = { + isa = XCSwiftPackageProductDependency; + package = 5AB9F3E72F7C1D03001F3337 /* XCLocalSwiftPackageReference "../Packages/TableProCore" */; + productName = TableProMSSQLCore; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 5AB9F3D12F7C1C12001F3337 /* Project object */; diff --git a/TableProMobile/TableProMobile/CBridges/CFreeTDS/CFreeTDS.h b/TableProMobile/TableProMobile/CBridges/CFreeTDS/CFreeTDS.h new file mode 100644 index 000000000..fce21d9a7 --- /dev/null +++ b/TableProMobile/TableProMobile/CBridges/CFreeTDS/CFreeTDS.h @@ -0,0 +1,7 @@ +#ifndef CFreeTDS_h +#define CFreeTDS_h + +#include +#include + +#endif diff --git a/TableProMobile/TableProMobile/CBridges/CFreeTDS/module.modulemap b/TableProMobile/TableProMobile/CBridges/CFreeTDS/module.modulemap new file mode 100644 index 000000000..e72f3e25f --- /dev/null +++ b/TableProMobile/TableProMobile/CBridges/CFreeTDS/module.modulemap @@ -0,0 +1,4 @@ +module CFreeTDS [system] { + header "CFreeTDS.h" + export * +} diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index 2995d9ae5..aede448f5 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -49,11 +49,13 @@ final class ConnectionCoordinator { var supportsDatabaseSwitching: Bool { connection.type == .mysql || connection.type == .mariadb || - connection.type == .postgresql || connection.type == .redshift + connection.type == .postgresql || connection.type == .redshift || + connection.type == .mssql } var supportsSchemas: Bool { - connection.type == .postgresql || connection.type == .redshift + connection.type == .postgresql || connection.type == .redshift || + connection.type == .mssql } init(connection: DatabaseConnection, appState: AppState) { diff --git a/TableProMobile/TableProMobile/Drivers/FreeTDSConnection.swift b/TableProMobile/TableProMobile/Drivers/FreeTDSConnection.swift new file mode 100644 index 000000000..5b315b945 --- /dev/null +++ b/TableProMobile/TableProMobile/Drivers/FreeTDSConnection.swift @@ -0,0 +1,503 @@ +import CFreeTDS +import Foundation +import os +import TableProMSSQLCore + +private let freetdsLogger = Logger(subsystem: "com.TablePro", category: "FreeTDSConnection") + +private let freetdsErrorLock = NSLock() +private var freetdsConnectionErrors: [UnsafeRawPointer: String] = [:] +private var freetdsGlobalError = "" + +private func freetdsGetError(for dbproc: UnsafeMutablePointer?) -> String { + freetdsErrorLock.lock() + defer { freetdsErrorLock.unlock() } + if let dbproc { + return freetdsConnectionErrors[UnsafeRawPointer(dbproc)] ?? freetdsGlobalError + } + return freetdsGlobalError +} + +private func freetdsClearError(for dbproc: UnsafeMutablePointer?) { + freetdsErrorLock.lock() + defer { freetdsErrorLock.unlock() } + if let dbproc { + freetdsConnectionErrors[UnsafeRawPointer(dbproc)] = nil + } else { + freetdsGlobalError = "" + } +} + +private func freetdsSetError(_ msg: String, for dbproc: UnsafeMutablePointer?, overwrite: Bool = false) { + freetdsErrorLock.lock() + defer { freetdsErrorLock.unlock() } + if let dbproc { + let key = UnsafeRawPointer(dbproc) + if overwrite || (freetdsConnectionErrors[key]?.isEmpty ?? true) { + freetdsConnectionErrors[key] = msg + } + } else if overwrite || freetdsGlobalError.isEmpty { + freetdsGlobalError = msg + } +} + +private func freetdsUnregister(_ dbproc: UnsafeMutablePointer) { + freetdsErrorLock.lock() + defer { freetdsErrorLock.unlock() } + freetdsConnectionErrors.removeValue(forKey: UnsafeRawPointer(dbproc)) +} + +private let freetdsInitOnce: Void = { + _ = dbinit() + _ = dberrhandle { dbproc, _, dberr, _, dberrstr, oserrstr in + var msg = "db-lib error \(dberr)" + if let s = dberrstr { msg += ": \(String(cString: s))" } + if let s = oserrstr, String(cString: s) != "Success" { msg += " (os: \(String(cString: s)))" } + freetdsLogger.error("FreeTDS: \(msg)") + freetdsSetError(msg, for: dbproc) + return INT_CANCEL + } + _ = dbmsghandle { dbproc, msgno, _, severity, msgtext, _, _, _ in + guard let text = msgtext else { return 0 } + let msg = String(cString: text) + if severity > 10 { + freetdsSetError(msg, for: dbproc, overwrite: true) + freetdsLogger.error("FreeTDS msg \(msgno) sev \(severity): \(msg)") + } else { + freetdsLogger.debug("FreeTDS msg \(msgno): \(msg)") + } + return 0 + } +}() + +private func freetdsDispatchAsync( + on queue: DispatchQueue, + execute work: @escaping @Sendable () throws -> T +) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + queue.async { + do { + let result = try work() + continuation.resume(returning: result) + } catch { + continuation.resume(throwing: error) + } + } + } +} + +private func freetdsDispatchAsync( + on queue: DispatchQueue, + execute work: @escaping @Sendable () throws -> Void +) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + queue.async { + do { + try work() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } +} + +final class FreeTDSConnection: @unchecked Sendable { + private var dbproc: UnsafeMutablePointer? + private let queue: DispatchQueue + private let options: MSSQLConnectionOptions + private let lock = NSLock() + private var _isConnected = false + private var _isCancelled = false + + var isConnected: Bool { + lock.lock() + defer { lock.unlock() } + return _isConnected + } + + init(options: MSSQLConnectionOptions) { + self.options = options + self.queue = DispatchQueue(label: "com.TablePro.freetds.\(options.host).\(options.port)", qos: .userInitiated) + _ = freetdsInitOnce + } + + func connect() async throws { + try await freetdsDispatchAsync(on: queue) { [self] in + try self.connectSync() + } + } + + private func connectSync() throws { + guard let login = dblogin() else { + throw MSSQLCoreError.connectionFailed("Failed to create login") + } + defer { dbloginfree(login) } + + _ = dbsetlname(login, options.user, Int32(DBSETUSER)) + _ = dbsetlname(login, options.password, Int32(DBSETPWD)) + _ = dbsetlname(login, options.applicationName, Int32(DBSETAPP)) + _ = dbsetlname(login, "us_english", Int32(DBSETNATLANG)) + _ = dbsetlname(login, "UTF-8", Int32(DBSETCHARSET)) + _ = dbsetlversion(login, UInt8(DBVERSION_74)) + _ = dbsetlname(login, options.encryptionFlag, Int32(DBSETENCRYPT)) + + freetdsClearError(for: nil) + let serverName = "\(options.host):\(options.port)" + guard let proc = dbopen(login, serverName) else { + let detail = freetdsGetError(for: nil) + let msg = detail.isEmpty ? "Check host, port, credentials, and TLS settings" : detail + throw MSSQLCoreError.connectionFailed("Failed to connect to \(options.host):\(options.port): \(msg)") + } + + if !options.database.isEmpty { + if dbuse(proc, options.database) == FAIL { + _ = dbclose(proc) + throw MSSQLCoreError.connectionFailed("Cannot open database '\(options.database)'") + } + } + + self.dbproc = proc + lock.lock() + _isConnected = true + lock.unlock() + } + + func switchDatabase(_ database: String) async throws { + try await freetdsDispatchAsync(on: queue) { [self] in + guard let proc = self.dbproc else { + throw MSSQLCoreError.notConnected + } + if dbuse(proc, database) == FAIL { + throw MSSQLCoreError.queryFailed("Cannot switch to database '\(database)'") + } + } + } + + func disconnect() { + let handle = dbproc + dbproc = nil + + lock.lock() + _isConnected = false + lock.unlock() + + if let handle { + freetdsUnregister(handle) + queue.async { + _ = dbclose(handle) + } + } + } + + func cancelCurrentQuery() { + lock.lock() + _isCancelled = true + let proc = dbproc + lock.unlock() + + guard let proc else { return } + dbcancel(proc) + } + + func executeQuery(_ query: String) async throws -> MSSQLRawResult { + let queryToRun = String(query) + return try await freetdsDispatchAsync(on: queue) { [self] in + try self.executeQuerySync(queryToRun) + } + } + + private func executeQuerySync(_ query: String) throws -> MSSQLRawResult { + guard let proc = dbproc else { + throw MSSQLCoreError.notConnected + } + + _ = dbcanquery(proc) + + lock.lock() + _isCancelled = false + lock.unlock() + + freetdsClearError(for: proc) + if dbcmd(proc, query) == FAIL { + throw MSSQLCoreError.queryFailed("Failed to prepare query") + } + if dbsqlexec(proc) == FAIL { + let detail = freetdsGetError(for: proc) + let msg = detail.isEmpty ? "Query execution failed" : detail + throw MSSQLCoreError.queryFailed(msg) + } + + var allColumns: [MSSQLColumnDescriptor] = [] + var allRows: [[MSSQLRawCell]] = [] + var firstResultSet = true + var truncated = false + + while true { + lock.lock() + let cancelledBetweenResults = _isCancelled + if cancelledBetweenResults { _isCancelled = false } + lock.unlock() + if cancelledBetweenResults { + throw CancellationError() + } + + let resCode = dbresults(proc) + if resCode == FAIL { + throw MSSQLCoreError.queryFailed("Query execution failed") + } + if resCode == Int32(NO_MORE_RESULTS) { + break + } + + let numCols = dbnumcols(proc) + if numCols <= 0 { continue } + + var descriptors: [MSSQLColumnDescriptor] = [] + for i in 1...numCols { + let name = dbcolname(proc, Int32(i)).map { String(cString: $0) } ?? "col\(i)" + let type = Self.columnType(fromFreeTDSToken: dbcoltype(proc, Int32(i))) + descriptors.append(MSSQLColumnDescriptor(name: name, type: type)) + } + + if firstResultSet { + allColumns = descriptors + firstResultSet = false + } + + while true { + let rowCode = dbnextrow(proc) + if rowCode == Int32(NO_MORE_ROWS) { break } + if rowCode == FAIL { break } + + lock.lock() + let cancelled = _isCancelled + if cancelled { _isCancelled = false } + lock.unlock() + if cancelled { + throw CancellationError() + } + + var row: [MSSQLRawCell] = [] + for i in 1...numCols { + let len = dbdatlen(proc, Int32(i)) + let colToken = dbcoltype(proc, Int32(i)) + let colType = descriptors[Int(i - 1)].type + if len <= 0 && colToken != Int32(SYBBIT) { + row.append(.null) + } else if let ptr = dbdata(proc, Int32(i)) { + if colType.isBinary { + row.append(.bytes(Data(bytes: ptr, count: Int(len)))) + } else if let str = Self.columnValueAsString(proc: proc, ptr: ptr, srcToken: colToken, srcLen: len, type: colType) { + row.append(.string(str)) + } else { + row.append(.null) + } + } else { + row.append(.null) + } + } + allRows.append(row) + if allRows.count >= MSSQLRowLimits.emergencyMax { + truncated = true + break + } + } + } + + let affectedRows = allColumns.isEmpty ? 0 : allRows.count + return MSSQLRawResult( + columns: allColumns, + rows: allRows, + affectedRows: affectedRows, + isTruncated: truncated + ) + } + + func streamQuery( + _ query: String, + continuation: AsyncThrowingStream.Continuation + ) async throws { + let queryToRun = String(query) + try await freetdsDispatchAsync(on: queue) { [self] in + try self.streamQuerySync(queryToRun, continuation: continuation) + } + } + + private func streamQuerySync( + _ query: String, + continuation: AsyncThrowingStream.Continuation + ) throws { + guard let proc = dbproc else { + throw MSSQLCoreError.notConnected + } + + _ = dbcanquery(proc) + + lock.lock() + _isCancelled = false + lock.unlock() + + freetdsClearError(for: proc) + if dbcmd(proc, query) == FAIL { + throw MSSQLCoreError.queryFailed("Failed to prepare query") + } + if dbsqlexec(proc) == FAIL { + let detail = freetdsGetError(for: proc) + let msg = detail.isEmpty ? "Query execution failed" : detail + throw MSSQLCoreError.queryFailed(msg) + } + + var headerSent = false + var currentDescriptors: [MSSQLColumnDescriptor] = [] + + while true { + lock.lock() + let cancelledBetweenResults = _isCancelled || Task.isCancelled + if cancelledBetweenResults { _isCancelled = false } + lock.unlock() + if cancelledBetweenResults { + continuation.finish(throwing: CancellationError()) + return + } + + let resCode = dbresults(proc) + if resCode == FAIL { + continuation.finish(throwing: MSSQLCoreError.queryFailed("Query execution failed")) + return + } + if resCode == Int32(NO_MORE_RESULTS) { + break + } + + let numCols = dbnumcols(proc) + if numCols <= 0 { continue } + + if !headerSent { + var descriptors: [MSSQLColumnDescriptor] = [] + for i in 1...numCols { + let name = dbcolname(proc, Int32(i)).map { String(cString: $0) } ?? "col\(i)" + let type = Self.columnType(fromFreeTDSToken: dbcoltype(proc, Int32(i))) + descriptors.append(MSSQLColumnDescriptor(name: name, type: type)) + } + currentDescriptors = descriptors + continuation.yield(.header(columns: descriptors)) + headerSent = true + } + + var batch: [[MSSQLRawCell]] = [] + batch.reserveCapacity(MSSQLRowLimits.streamBatchSize) + + while true { + let rowCode = dbnextrow(proc) + if rowCode == Int32(NO_MORE_ROWS) { break } + if rowCode == FAIL { break } + + lock.lock() + let cancelled = _isCancelled || Task.isCancelled + if cancelled { _isCancelled = false } + lock.unlock() + if cancelled { + if !batch.isEmpty { + continuation.yield(.rows(batch)) + } + continuation.finish(throwing: CancellationError()) + return + } + + var row: [MSSQLRawCell] = [] + for i in 1...numCols { + let len = dbdatlen(proc, Int32(i)) + let colToken = dbcoltype(proc, Int32(i)) + let colType = currentDescriptors[Int(i - 1)].type + if len <= 0 && colToken != Int32(SYBBIT) { + row.append(.null) + } else if let ptr = dbdata(proc, Int32(i)) { + if colType.isBinary { + row.append(.bytes(Data(bytes: ptr, count: Int(len)))) + } else if let str = Self.columnValueAsString(proc: proc, ptr: ptr, srcToken: colToken, srcLen: len, type: colType) { + row.append(.string(str)) + } else { + row.append(.null) + } + } else { + row.append(.null) + } + } + batch.append(row) + if batch.count >= MSSQLRowLimits.streamBatchSize { + continuation.yield(.rows(batch)) + batch.removeAll(keepingCapacity: true) + } + } + + if !batch.isEmpty { + continuation.yield(.rows(batch)) + } + } + + continuation.finish() + } + + static func columnType(fromFreeTDSToken token: Int32) -> MSSQLColumnType { + switch token { + case Int32(SYBCHAR): return .char + case Int32(SYBVARCHAR): return .varchar + case Int32(SYBTEXT): return .text + case Int32(SYBNCHAR): return .nchar + case Int32(SYBNVARCHAR): return .nvarchar + case Int32(SYBNTEXT): return .ntext + case Int32(SYBINT1): return .tinyInt + case Int32(SYBINT2): return .smallInt + case Int32(SYBINT4): return .int + case Int32(SYBINT8): return .bigInt + case Int32(SYBFLT8): return .float + case Int32(SYBREAL): return .real + case Int32(SYBDECIMAL), Int32(SYBNUMERIC): return .decimal + case Int32(SYBMONEY): return .money + case Int32(SYBMONEY4): return .smallMoney + case Int32(SYBBIT): return .bit + case Int32(SYBBINARY): return .binary + case Int32(SYBVARBINARY): return .varbinary + case Int32(SYBIMAGE): return .image + case Int32(SYBDATETIME): return .dateTime + case Int32(SYBDATETIME4): return .smallDateTime + case Int32(SYBDATETIMN): return .dateTimeN + case 40: return .date + case 41: return .time + case 42: return .dateTime2 + case 43: return .dateTimeOffset + case Int32(SYBUNIQUE): return .uniqueIdentifier + default: return .unknown(token) + } + } + + private static func columnValueAsString( + proc: UnsafeMutablePointer, + ptr: UnsafePointer, + srcToken: Int32, + srcLen: DBINT, + type: MSSQLColumnType + ) -> String? { + if type.isNarrowString { + return String(bytes: UnsafeBufferPointer(start: ptr, count: Int(srcLen)), encoding: .utf8) + ?? String(bytes: UnsafeBufferPointer(start: ptr, count: Int(srcLen)), encoding: .isoLatin1) + } + if type.isUnicodeString { + return String(bytes: UnsafeBufferPointer(start: ptr, count: Int(srcLen)), encoding: .utf8) + ?? String(data: Data(bytes: ptr, count: Int(srcLen)), encoding: .utf16LittleEndian) + } + let bufSize: DBINT = 256 + var buf = [BYTE](repeating: 0, count: Int(bufSize)) + let converted = buf.withUnsafeMutableBufferPointer { bufPtr in + dbconvert(proc, srcToken, ptr, srcLen, Int32(SYBCHAR), bufPtr.baseAddress, bufSize) + } + guard converted > 0, + let raw = String(bytes: buf.prefix(Int(converted)), encoding: .utf8) + else { return nil } + if type.isDateOrTime { + return MSSQLDatetimeFormatter.reformat(raw, type: type) ?? raw + } + return raw + } +} diff --git a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift new file mode 100644 index 000000000..9e69ad959 --- /dev/null +++ b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift @@ -0,0 +1,291 @@ +import Foundation +import TableProDatabase +import TableProModels +import TableProMSSQLCore + +private extension MSSQLRawResult { + func toQueryResult(executionTime: TimeInterval) -> QueryResult { + let columnInfos = columns.enumerated().map { idx, col in + ColumnInfo(name: col.name, typeName: col.type.canonicalName, ordinalPosition: idx) + } + return QueryResult( + columns: columnInfos, + rows: rows.map { row in row.map { $0.stringValue } }, + rowsAffected: affectedRows, + executionTime: executionTime, + isTruncated: isTruncated + ) + } +} + +final class MSSQLDriver: DatabaseDriver, @unchecked Sendable { + private let conn: FreeTDSConnection + private let host: String + + var supportsSchemas: Bool { true } + var supportsTransactions: Bool { true } + + nonisolated(unsafe) private(set) var currentSchema: String? = "dbo" + nonisolated(unsafe) private(set) var serverVersion: String? + + init(connection: DatabaseConnection, password: String?) { + let options = MSSQLConnectionOptions( + host: connection.host, + port: connection.port, + user: connection.username, + password: password ?? "", + database: connection.database, + schema: MSSQLConnectionOptions.schema(from: connection.additionalFields), + encryptionFlag: Self.freetdsEncryptionFlag(for: connection.sslConfiguration) + ) + self.conn = FreeTDSConnection(options: options) + self.host = connection.host + self.currentSchema = options.schema + } + + private static func freetdsEncryptionFlag(for ssl: SSLConfiguration?) -> String { + guard let mode = ssl?.mode else { return "off" } + switch mode { + case .disable: return "off" + case .require: return "require" + case .verifyCa, .verifyFull: return "require" + } + } + + private var escapedSchema: String { + (currentSchema ?? "dbo").replacingOccurrences(of: "'", with: "''") + } + + // MARK: - Connection + + func connect() async throws { + try await LocalNetworkPermission.shared.ensureAccess(for: host) + do { + try await conn.connect() + } catch let error as MSSQLCoreError { + throw mapToConnectionError(error) + } + + if let serverSchema = try? await runQuery(MSSQLSchemaQueries.currentSchema).rows.first?.first ?? nil, + !serverSchema.isEmpty { + currentSchema = serverSchema + } + + if let version = try? await runQuery(MSSQLSchemaQueries.serverVersion).rows.first?.first ?? nil { + serverVersion = String(version.prefix(50)) + } + } + + func disconnect() async throws { + conn.disconnect() + } + + func ping() async throws -> Bool { + _ = try await runQuery(MSSQLSchemaQueries.ping) + return true + } + + // MARK: - Query Execution + + func execute(query: String) async throws -> QueryResult { + try await runQuery(query) + } + + private func runQuery(_ query: String) async throws -> QueryResult { + let startTime = Date() + do { + let raw = try await conn.executeQuery(query) + return raw.toQueryResult(executionTime: Date().timeIntervalSince(startTime)) + } catch let error as MSSQLCoreError { + throw mapToConnectionError(error) + } + } + + func cancelCurrentQuery() async throws { + conn.cancelCurrentQuery() + } + + func executeStreaming(query: String, options: StreamOptions) -> AsyncThrowingStream { + AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in + let task = Task { + let coreStream = AsyncThrowingStream { coreContinuation in + Task { + do { + try await conn.streamQuery(query, continuation: coreContinuation) + } catch { + coreContinuation.finish(throwing: error) + } + } + } + var emitted = 0 + var headerColumns: [ColumnInfo] = [] + do { + for try await element in coreStream { + if Task.isCancelled { + continuation.yield(.truncated(reason: .cancelled)) + continuation.finish() + return + } + switch element { + case .header(let columns): + headerColumns = columns.enumerated().map { idx, col in + ColumnInfo(name: col.name, typeName: col.type.canonicalName, ordinalPosition: idx) + } + continuation.yield(.columns(headerColumns)) + case .rows(let batch): + for row in batch { + if emitted >= options.maxRows { + continuation.yield(.truncated(reason: .rowCap(options.maxRows))) + continuation.finish() + return + } + let cells = zip(headerColumns, row).map { columnInfo, rawCell in + cell(from: rawCell, columnTypeName: columnInfo.typeName, options: options) + } + continuation.yield(.row(Row(cells: cells))) + emitted += 1 + } + case .affectedRows(let count): + continuation.yield(.rowsAffected(count)) + } + } + continuation.finish() + } catch let error as MSSQLCoreError { + continuation.finish(throwing: mapToConnectionError(error)) + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { @Sendable _ in + task.cancel() + } + } + } + + private func cell(from raw: MSSQLRawCell, columnTypeName: String, options: StreamOptions) -> Cell { + switch raw { + case .null: + return .null + case .string(let value): + return Cell.from(legacyValue: value, columnTypeName: columnTypeName, options: options) + case .bytes(let data): + return .binary(byteCount: data.count, ref: nil) + } + } + + // MARK: - Transactions + + func beginTransaction() async throws { + _ = try await runQuery(MSSQLSchemaQueries.beginTransaction) + } + + func commitTransaction() async throws { + _ = try await runQuery(MSSQLSchemaQueries.commitTransaction) + } + + func rollbackTransaction() async throws { + _ = try await runQuery(MSSQLSchemaQueries.rollbackTransaction) + } + + // MARK: - Database & Schema Navigation + + func fetchDatabases() async throws -> [String] { + let result = try await runQuery(MSSQLSchemaQueries.databases) + return result.rows.compactMap { $0.first ?? nil } + } + + func switchDatabase(to name: String) async throws { + do { + try await conn.switchDatabase(name) + } catch let error as MSSQLCoreError { + throw mapToConnectionError(error) + } + } + + func fetchSchemas() async throws -> [String] { + let result = try await runQuery(MSSQLSchemaQueries.schemas) + return result.rows.compactMap { $0.first ?? nil } + } + + func switchSchema(to name: String) async throws { + currentSchema = name + } + + // MARK: - Table Metadata + + private var effectiveSchema: String { currentSchema ?? "dbo" } + + func fetchTables(schema: String?) async throws -> [TableInfo] { + let result = try await runQuery(MSSQLSchemaQueries.tables(schema: schema ?? effectiveSchema)) + return result.rows.compactMap { row in + MSSQLSchemaQueries.parseTableRow(row).map { + TableInfo(name: $0.name, type: $0.isView ? .view : .table) + } + } + } + + func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { + let result = try await runQuery(MSSQLSchemaQueries.columns(schema: schema ?? effectiveSchema, table: table)) + return result.rows.enumerated().compactMap { idx, row in + MSSQLSchemaQueries.parseColumnRow(row).map { parsed in + ColumnInfo( + name: parsed.name, + typeName: parsed.displayType, + isPrimaryKey: parsed.isPrimaryKey, + isNullable: parsed.isNullable, + defaultValue: parsed.defaultValue, + characterMaxLength: parsed.characterMaxLength, + ordinalPosition: idx + ) + } + } + } + + func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { + let result = try await runQuery(MSSQLSchemaQueries.indexes(schema: schema ?? effectiveSchema, table: table)) + var byName: [String: (unique: Bool, primary: Bool, cols: [String])] = [:] + for row in result.rows { + guard let parsed = MSSQLSchemaQueries.parseIndexRow(row) else { continue } + if byName[parsed.name] == nil { + byName[parsed.name] = (parsed.isUnique, parsed.isPrimary, []) + } + byName[parsed.name]?.cols.append(parsed.columnName) + } + return byName.map { name, info in + IndexInfo(name: name, columns: info.cols, isUnique: info.unique, isPrimary: info.primary, type: "CLUSTERED") + }.sorted { $0.name < $1.name } + } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { + let result = try await runQuery(MSSQLSchemaQueries.foreignKeys(schema: schema ?? effectiveSchema, table: table)) + return result.rows.compactMap { row in + MSSQLSchemaQueries.parseForeignKeyRow(row).map { + ForeignKeyInfo(name: $0.constraintName, column: $0.columnName, + referencedTable: $0.referencedTable, referencedColumn: $0.referencedColumn) + } + } + } + + // MARK: - Error Mapping + + private func mapToConnectionError(_ error: MSSQLCoreError) -> Error { + switch error { + case .notConnected: + return ConnectionError.notConnected + case .connectionFailed(let msg): + return DatabaseError(message: msg) + case .tlsHandshakeFailed(let msg): + return DatabaseError(message: "TLS handshake failed: \(msg)") + case .queryFailed(let msg): + return DatabaseError(message: msg) + case .cancelled: + return CancellationError() + } + } +} + +private extension Array { + subscript(safe index: Int) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift b/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift index 52971bd05..a7c17b3f5 100644 --- a/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift +++ b/TableProMobile/TableProMobile/Helpers/DatabaseType+Mobile.swift @@ -8,6 +8,7 @@ extension DatabaseType { case .postgresql: return "5432" case .redshift: return "5439" case .redis: return "6379" + case .mssql: return "1433" case .sqlite: return "" default: return "3306" } @@ -21,6 +22,7 @@ extension DatabaseType { case .redshift: "Redshift" case .sqlite: "SQLite" case .redis: "Redis" + case .mssql: "SQL Server" default: rawValue.uppercased() } } @@ -30,6 +32,7 @@ extension DatabaseType { .mariadb, .postgresql, .sqlite, - .redis + .redis, + .mssql ] } diff --git a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift index ffd5e551f..f59f8c27b 100644 --- a/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift +++ b/TableProMobile/TableProMobile/Helpers/SQLBuilder.swift @@ -10,11 +10,24 @@ enum SQLBuilder { return "`\(name.replacingOccurrences(of: "`", with: "``"))`" case .postgresql, .redshift: return "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + case .mssql: + return "[\(name.replacingOccurrences(of: "]", with: "]]"))]" default: return "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" } } + static func paginationClause(orderBy: String, limit: Int, offset: Int, for type: DatabaseType) -> String { + switch type { + case .mssql: + let order = orderBy.isEmpty ? "ORDER BY (SELECT NULL)" : orderBy + return "\(order) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + default: + let trailing = "LIMIT \(limit) OFFSET \(offset)" + return orderBy.isEmpty ? trailing : "\(orderBy) \(trailing)" + } + } + static func escapeString(_ value: String) -> String { value .replacingOccurrences(of: "\\", with: "\\\\") @@ -32,7 +45,8 @@ enum SQLBuilder { static func buildSelect(table: String, type: DatabaseType, limit: Int, offset: Int) -> String { let quoted = quoteIdentifier(table, for: type) - return "SELECT * FROM \(quoted) LIMIT \(limit) OFFSET \(offset)" + let pagination = paginationClause(orderBy: "", limit: limit, offset: offset, for: type) + return "SELECT * FROM \(quoted) \(pagination)" } static func buildDelete( @@ -87,8 +101,8 @@ enum SQLBuilder { ) -> String { let quoted = quoteIdentifier(table, for: type) let orderBy = buildOrderByClause(sortState, for: type) - return "SELECT * FROM \(quoted) \(orderBy) LIMIT \(limit) OFFSET \(offset)" - .replacingOccurrences(of: " ", with: " ") + let pagination = paginationClause(orderBy: orderBy, limit: limit, offset: offset, for: type) + return "SELECT * FROM \(quoted) \(pagination)" } static func buildFilteredSelect( @@ -100,10 +114,11 @@ enum SQLBuilder { let generator = FilterSQLGenerator(dialect: dialect) let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode) let quoted = quoteIdentifier(table, for: type) - if whereClause.isEmpty { - return "SELECT * FROM \(quoted) LIMIT \(limit) OFFSET \(offset)" - } - return "SELECT * FROM \(quoted) \(whereClause) LIMIT \(limit) OFFSET \(offset)" + let pagination = paginationClause(orderBy: "", limit: limit, offset: offset, for: type) + var sql = "SELECT * FROM \(quoted)" + if !whereClause.isEmpty { sql += " \(whereClause)" } + sql += " \(pagination)" + return sql } static func buildFilteredSelect( @@ -117,10 +132,10 @@ enum SQLBuilder { let whereClause = generator.generateWhereClause(from: filters, logicMode: logicMode) let orderBy = buildOrderByClause(sortState, for: type) let quoted = quoteIdentifier(table, for: type) + let pagination = paginationClause(orderBy: orderBy, limit: limit, offset: offset, for: type) var sql = "SELECT * FROM \(quoted)" if !whereClause.isEmpty { sql += " \(whereClause)" } - if !orderBy.isEmpty { sql += " \(orderBy)" } - sql += " LIMIT \(limit) OFFSET \(offset)" + sql += " \(pagination)" return sql } @@ -152,11 +167,11 @@ enum SQLBuilder { searchText: searchText, searchColumns: searchColumns, filters: filters, logicMode: logicMode, type: type ) + let orderBy = buildOrderByClause(sortState, for: type) + let pagination = paginationClause(orderBy: orderBy, limit: limit, offset: offset, for: type) var sql = "SELECT * FROM \(quoted)" if !whereClause.isEmpty { sql += " \(whereClause)" } - let orderBy = buildOrderByClause(sortState, for: type) - if !orderBy.isEmpty { sql += " \(orderBy)" } - sql += " LIMIT \(limit) OFFSET \(offset)" + sql += " \(pagination)" return sql } @@ -279,6 +294,14 @@ enum SQLBuilder { dataTypes: [], likeEscapeStyle: .explicit ) + case .mssql: + return SQLDialectDescriptor( + identifierQuote: "[", + keywords: [], + functions: [], + dataTypes: [], + likeEscapeStyle: .explicit + ) default: return SQLDialectDescriptor( identifierQuote: "\"", diff --git a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift index c32363a0f..c36f19dee 100644 --- a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift +++ b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift @@ -34,12 +34,14 @@ final class IOSDriverFactory: DriverFactory { database: dbIndex, sslEnabled: connection.sslEnabled ) + case .mssql: + return MSSQLDriver(connection: connection, password: password) default: throw ConnectionError.driverNotFound(connection.type.rawValue) } } func supportedTypes() -> [DatabaseType] { - [.sqlite, .mysql, .mariadb, .postgresql, .redshift, .redis] + [.sqlite, .mysql, .mariadb, .postgresql, .redshift, .redis, .mssql] } } diff --git a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift index 6425eb9f9..b9d0986c1 100644 --- a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -337,14 +337,20 @@ final class DataBrowserViewModel { // MARK: - Lazy Cell Loading - func loadFullValue(driver: DatabaseDriver, ref: CellRef) async throws -> String? { + func loadFullValue(driver: DatabaseDriver, ref: CellRef, databaseType: DatabaseType) async throws -> String? { let predicates = ref.primaryKey.map { component in - "\"\(component.column.replacingOccurrences(of: "\"", with: "\"\""))\" = '\(component.value.replacingOccurrences(of: "'", with: "''"))'" + "\(SQLBuilder.quoteIdentifier(component.column, for: databaseType)) = '\(component.value.replacingOccurrences(of: "'", with: "''"))'" } let predicate = predicates.joined(separator: " AND ") - let column = "\"\(ref.column.replacingOccurrences(of: "\"", with: "\"\""))\"" - let table = "\"\(ref.table.replacingOccurrences(of: "\"", with: "\"\""))\"" - let query = "SELECT \(column) FROM \(table) WHERE \(predicate) LIMIT 1" + let column = SQLBuilder.quoteIdentifier(ref.column, for: databaseType) + let table = SQLBuilder.quoteIdentifier(ref.table, for: databaseType) + let query: String + switch databaseType { + case .mssql: + query = "SELECT TOP 1 \(column) FROM \(table) WHERE \(predicate)" + default: + query = "SELECT \(column) FROM \(table) WHERE \(predicate) LIMIT 1" + } let result = try await driver.execute(query: query) return result.rows.first?.first ?? nil diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index aca043952..e25a039ed 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -251,7 +251,7 @@ struct DataBrowserView: View { onSaved: { Task { await viewModel.load() } }, loadFullValue: { ref in guard let session else { return nil } - return try await viewModel.loadFullValue(driver: session.driver, ref: ref) + return try await viewModel.loadFullValue(driver: session.driver, ref: ref, databaseType: connection.type) } ) } label: { diff --git a/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift b/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift new file mode 100644 index 000000000..30450f3d4 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift @@ -0,0 +1,128 @@ +import XCTest +import TableProDatabase +import TableProModels +@testable import TableProMobile + +/// Integration tests for the iOS MSSQL driver against a real SQL Server instance. +/// +/// All tests skip unless `MSSQL_TEST_HOST` is set in the environment. To run locally: +/// ``` +/// MSSQL_TEST_HOST=localhost MSSQL_TEST_USER=sa MSSQL_TEST_PASSWORD='YourStrong!Pass' \ +/// xcodebuild test -scheme TableProMobile -only-testing:TableProMobileTests/MSSQLDriverTests +/// ``` +final class MSSQLDriverTests: XCTestCase { + private var driver: MSSQLDriver? + + private static func loadTestConfig() -> [String: String]? { + let env = ProcessInfo.processInfo.environment + if env["MSSQL_TEST_HOST"] != nil { + return env + } + let fallbackPath = "/tmp/mssql-test.json" + guard let data = FileManager.default.contents(atPath: fallbackPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: String] + else { return nil } + return json + } + + override func setUp() async throws { + guard let config = Self.loadTestConfig() else { + throw XCTSkip("MSSQL_TEST_HOST not set and /tmp/mssql-test.json not found, skipping integration tests") + } + let mode: SSLConfiguration.SSLMode + switch config["MSSQL_TEST_SSL_MODE"] ?? "disable" { + case "require": mode = .require + case "verifyCa": mode = .verifyCa + case "verifyFull": mode = .verifyFull + default: mode = .disable + } + let connection = DatabaseConnection( + name: "test", + type: .mssql, + host: config["MSSQL_TEST_HOST"] ?? "localhost", + port: Int(config["MSSQL_TEST_PORT"] ?? "1433") ?? 1433, + username: config["MSSQL_TEST_USER"] ?? "sa", + database: config["MSSQL_TEST_DATABASE"] ?? "master", + additionalFields: ["mssqlSchema": "dbo"], + sslEnabled: mode != .disable, + sslConfiguration: SSLConfiguration(mode: mode) + ) + let password = config["MSSQL_TEST_PASSWORD"] ?? "" + driver = MSSQLDriver(connection: connection, password: password) + try await driver?.connect() + } + + override func tearDown() async throws { + try await driver?.disconnect() + driver = nil + } + + func testConnectAndPing() async throws { + let driver = try XCTUnwrap(driver) + let pong = try await driver.ping() + XCTAssertTrue(pong) + XCTAssertNotNil(driver.serverVersion) + } + + func testSimpleQuery() async throws { + let driver = try XCTUnwrap(driver) + let result = try await driver.execute(query: "SELECT 1 AS one, 'hello' AS greeting") + XCTAssertEqual(result.columns.map { $0.name }, ["one", "greeting"]) + XCTAssertEqual(result.rows.first?[0], "1") + XCTAssertEqual(result.rows.first?[1], "hello") + } + + func testFetchDatabasesIncludesMaster() async throws { + let driver = try XCTUnwrap(driver) + let names = try await driver.fetchDatabases() + XCTAssertTrue(names.contains("master")) + } + + func testFetchSchemasExcludesSystemSchemas() async throws { + let driver = try XCTUnwrap(driver) + let names = try await driver.fetchSchemas() + XCTAssertFalse(names.contains("sys")) + XCTAssertFalse(names.contains("information_schema")) + } + + func testFetchTablesReturnsResults() async throws { + let driver = try XCTUnwrap(driver) + _ = try await driver.execute(query: """ + IF OBJECT_ID('dbo.tablepro_test_table', 'U') IS NULL + CREATE TABLE dbo.tablepro_test_table (id INT PRIMARY KEY IDENTITY(1,1), name NVARCHAR(100)) + """) + let tables = try await driver.fetchTables(schema: "dbo") + XCTAssertTrue(tables.contains { $0.name == "tablepro_test_table" && $0.type == .table }) + } + + func testFetchColumnsReturnsTypeMetadata() async throws { + let driver = try XCTUnwrap(driver) + _ = try await driver.execute(query: """ + IF OBJECT_ID('dbo.tablepro_test_table', 'U') IS NULL + CREATE TABLE dbo.tablepro_test_table (id INT PRIMARY KEY IDENTITY(1,1), name NVARCHAR(100)) + """) + let columns = try await driver.fetchColumns(table: "tablepro_test_table", schema: "dbo") + XCTAssertEqual(columns.count, 2) + let id = columns.first { $0.name == "id" } + XCTAssertEqual(id?.isPrimaryKey, true) + XCTAssertEqual(id?.typeName, "int") + let name = columns.first { $0.name == "name" } + XCTAssertEqual(name?.typeName, "nvarchar(100)") + } + + func testExplicitTransactionRollback() async throws { + let driver = try XCTUnwrap(driver) + _ = try await driver.execute(query: """ + IF OBJECT_ID('dbo.tablepro_tx_test', 'U') IS NULL + CREATE TABLE dbo.tablepro_tx_test (v INT) + """) + _ = try await driver.execute(query: "DELETE FROM dbo.tablepro_tx_test") + + try await driver.beginTransaction() + _ = try await driver.execute(query: "INSERT INTO dbo.tablepro_tx_test VALUES (42)") + try await driver.rollbackTransaction() + + let result = try await driver.execute(query: "SELECT COUNT(*) FROM dbo.tablepro_tx_test") + XCTAssertEqual(result.rows.first?.first, "0") + } +} diff --git a/scripts/build-freetds.sh b/scripts/build-freetds.sh index 7aee20573..7912be093 100755 --- a/scripts/build-freetds.sh +++ b/scripts/build-freetds.sh @@ -1,79 +1,185 @@ #!/usr/bin/env bash -# Build FreeTDS static libraries for arm64 and x86_64, then lipo-merge to universal. -# Outputs to Libs/ and copies headers to TablePro/Core/Database/CFreeTDS/include/ +# Build FreeTDS static libraries for macOS (arm64 + x86_64) and iOS (arm64 device + arm64 simulator), +# then package as a unified xcframework with bundled Swift module map. +# +# Output: Libs/ios/FreeTDS.xcframework, consumed by both the macOS MSSQL plugin and the iOS app +# via the CFreeTDS Swift module. +# +# Prerequisites: +# brew install autoconf automake libtool openssl@3 +# Xcode 15+ (for xcrun, xcodebuild -create-xcframework) +# Libs/ios/OpenSSL-SSL.xcframework + OpenSSL-Crypto.xcframework already downloaded +# (run scripts/download-libs.sh first if needed) # # Usage: bash scripts/build-freetds.sh -# Prerequisites: brew install autoconf automake libtool -set -e +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" LIBS_DIR="$PROJECT_DIR/Libs" +IOS_LIBS_DIR="$LIBS_DIR/ios" FREETDS_VERSION="1.4.22" FREETDS_SHA256="6acb9086350425f5178e544bbe2d54a001097e8e20277a2b766ad0799a2e7d87" FREETDS_URL="https://www.freetds.org/files/stable/freetds-${FREETDS_VERSION}.tar.gz" BUILD_DIR="/tmp/freetds-build" -INCLUDE_DST="$PROJECT_DIR/TablePro/Core/Database/CFreeTDS/include" +SOURCE_DIR="$BUILD_DIR/freetds-${FREETDS_VERSION}" +MACOS_DEPLOYMENT_TARGET="14.0" +IOS_DEPLOYMENT_TARGET="17.0" + +MACOS_OPENSSL_PREFIX="$(brew --prefix openssl@3)" +IOS_OPENSSL_SSL_XCFW="$IOS_LIBS_DIR/OpenSSL-SSL.xcframework" +IOS_OPENSSL_CRYPTO_XCFW="$IOS_LIBS_DIR/OpenSSL-Crypto.xcframework" + +if [ ! -d "$IOS_OPENSSL_SSL_XCFW" ] || [ ! -d "$IOS_OPENSSL_CRYPTO_XCFW" ]; then + echo "ERROR: iOS OpenSSL xcframeworks not found in $IOS_LIBS_DIR" + echo "Run scripts/download-libs.sh first." + exit 1 +fi -mkdir -p "$BUILD_DIR" "$LIBS_DIR" "$INCLUDE_DST" +mkdir -p "$BUILD_DIR" "$LIBS_DIR" "$IOS_LIBS_DIR" -echo "Downloading FreeTDS ${FREETDS_VERSION}..." -curl -fSL "$FREETDS_URL" -o "$BUILD_DIR/freetds-${FREETDS_VERSION}.tar.gz" +echo "==> Downloading FreeTDS ${FREETDS_VERSION}..." +if [ ! -f "$BUILD_DIR/freetds-${FREETDS_VERSION}.tar.gz" ]; then + curl -fSL "$FREETDS_URL" -o "$BUILD_DIR/freetds-${FREETDS_VERSION}.tar.gz" +fi echo "$FREETDS_SHA256 $BUILD_DIR/freetds-${FREETDS_VERSION}.tar.gz" | shasum -a 256 -c - + +rm -rf "$SOURCE_DIR" tar xz -C "$BUILD_DIR" -f "$BUILD_DIR/freetds-${FREETDS_VERSION}.tar.gz" -SOURCE_DIR="$BUILD_DIR/freetds-${FREETDS_VERSION}" -build_arch() { - local ARCH="$1" - local PREFIX="/tmp/freetds-${ARCH}" - local HOST_TRIPLE - if [ "$ARCH" = "arm64" ]; then - HOST_TRIPLE="aarch64-apple-darwin" - else - HOST_TRIPLE="x86_64-apple-darwin" - fi - - echo "Building FreeTDS for ${ARCH}..." +build_slice() { + local SLICE_LABEL="$1" + local SDK="$2" + local ARCH="$3" + local HOST_TRIPLE="$4" + local VERSION_FLAG="$5" + local OPENSSL_PREFIX="$6" + + local PREFIX="/tmp/freetds-${SLICE_LABEL}" + local SDKPATH + SDKPATH="$(xcrun --sdk "$SDK" --show-sdk-path)" + local CC_BIN + CC_BIN="$(xcrun -sdk "$SDK" -find clang)" + + echo "==> Building FreeTDS for ${SLICE_LABEL} (${ARCH}, ${SDK})..." + rm -rf "$PREFIX" pushd "$SOURCE_DIR" > /dev/null make distclean 2>/dev/null || true - ./configure \ - --prefix="$PREFIX" \ - --host="$HOST_TRIPLE" \ - --disable-shared \ - --enable-static \ - --disable-odbc \ - --with-tdsver=7.4 \ - CFLAGS="-arch ${ARCH} -mmacosx-version-min=14.0" \ - LDFLAGS="-arch ${ARCH}" - make -j"$(sysctl -n hw.logicalcpu)" - make install + rm -f config.cache + + # Pre-seed AC_RUN_IFELSE results via env vars (correct for all 64-bit Apple platforms). + # Avoids --cache-file which autoconf rejects when host/CFLAGS change between slices. + env \ + ac_cv_func_malloc_0_nonnull=yes \ + ac_cv_func_realloc_0_nonnull=yes \ + ac_cv_func_memcmp_working=yes \ + ac_cv_func_iconv_open=yes \ + ac_cv_sizeof_int=4 \ + ac_cv_sizeof_long=8 \ + ac_cv_sizeof_long_long=8 \ + ac_cv_sizeof_void_p=8 \ + ac_cv_c_bigendian=no \ + ./configure \ + --prefix="$PREFIX" \ + --host="$HOST_TRIPLE" \ + --disable-shared \ + --enable-static \ + --disable-odbc \ + --disable-libiconv \ + --with-tdsver=7.4 \ + --with-openssl="$OPENSSL_PREFIX" \ + CC="$CC_BIN" \ + CFLAGS="-arch ${ARCH} -isysroot ${SDKPATH} ${VERSION_FLAG} -I${OPENSSL_PREFIX}/include" \ + LDFLAGS="-arch ${ARCH} -isysroot ${SDKPATH} -L${OPENSSL_PREFIX}/lib" + + # Build only the libraries we need (skip src/apps which require readline + native exec). + # SUBDIRS order matches src/Makefile: utils → replacements → tds → dblib. + make -j"$(sysctl -n hw.logicalcpu)" -C include + make -j"$(sysctl -n hw.logicalcpu)" -C src/utils + make -j"$(sysctl -n hw.logicalcpu)" -C src/replacements + make -j"$(sysctl -n hw.logicalcpu)" -C src/tds + make -j"$(sysctl -n hw.logicalcpu)" -C src/dblib + make -C src/dblib install popd > /dev/null - cp "$PREFIX/lib/libsybdb.a" "$LIBS_DIR/libsybdb_${ARCH}.a" - echo "Built libsybdb_${ARCH}.a" + cp "$PREFIX/lib/libsybdb.a" "$LIBS_DIR/libsybdb_${SLICE_LABEL}.a" + echo " built libsybdb_${SLICE_LABEL}.a" } -build_arch "arm64" -build_arch "x86_64" +# macOS slices use per-arch static OpenSSL from Libs/ to avoid brew's arm64-only dylib at the +# linker step. Brew supplies headers (arch-agnostic); the .a files come from Libs/. +MACOS_OPENSSL_ARM64="$(mktemp -d)/openssl-macos-arm64" +MACOS_OPENSSL_X86_64="$(mktemp -d)/openssl-macos-x86_64" +mkdir -p "$MACOS_OPENSSL_ARM64/include/openssl" "$MACOS_OPENSSL_ARM64/lib" +mkdir -p "$MACOS_OPENSSL_X86_64/include/openssl" "$MACOS_OPENSSL_X86_64/lib" +cp -R "$MACOS_OPENSSL_PREFIX/include/openssl/." "$MACOS_OPENSSL_ARM64/include/openssl/" +cp -R "$MACOS_OPENSSL_PREFIX/include/openssl/." "$MACOS_OPENSSL_X86_64/include/openssl/" +cp "$LIBS_DIR/libssl_arm64.a" "$MACOS_OPENSSL_ARM64/lib/libssl.a" +cp "$LIBS_DIR/libcrypto_arm64.a" "$MACOS_OPENSSL_ARM64/lib/libcrypto.a" +cp "$LIBS_DIR/libssl_x86_64.a" "$MACOS_OPENSSL_X86_64/lib/libssl.a" +cp "$LIBS_DIR/libcrypto_x86_64.a" "$MACOS_OPENSSL_X86_64/lib/libcrypto.a" + +build_slice "macos-arm64" "macosx" "arm64" "aarch64-apple-darwin" "-mmacosx-version-min=${MACOS_DEPLOYMENT_TARGET}" "$MACOS_OPENSSL_ARM64" +build_slice "macos-x86_64" "macosx" "x86_64" "x86_64-apple-darwin" "-mmacosx-version-min=${MACOS_DEPLOYMENT_TARGET}" "$MACOS_OPENSSL_X86_64" + +# iOS slices link OpenSSL statically from the existing xcframeworks; reconstruct a unix-style prefix +# for FreeTDS's --with-openssl which expects include/ and lib/ siblings. +IOS_OPENSSL_DEVICE="$(mktemp -d)/openssl-ios-arm64" +IOS_OPENSSL_SIM="$(mktemp -d)/openssl-ios-arm64-simulator" +mkdir -p "$IOS_OPENSSL_DEVICE/include" "$IOS_OPENSSL_DEVICE/lib" +mkdir -p "$IOS_OPENSSL_SIM/include" "$IOS_OPENSSL_SIM/lib" +cp -R "$IOS_OPENSSL_SSL_XCFW/ios-arm64/Headers/." "$IOS_OPENSSL_DEVICE/include/" +cp "$IOS_OPENSSL_SSL_XCFW/ios-arm64/libssl.a" "$IOS_OPENSSL_DEVICE/lib/" +cp "$IOS_OPENSSL_CRYPTO_XCFW/ios-arm64/libcrypto.a" "$IOS_OPENSSL_DEVICE/lib/" +cp -R "$IOS_OPENSSL_SSL_XCFW/ios-arm64-simulator/Headers/." "$IOS_OPENSSL_SIM/include/" +cp "$IOS_OPENSSL_SSL_XCFW/ios-arm64-simulator/libssl.a" "$IOS_OPENSSL_SIM/lib/" +cp "$IOS_OPENSSL_CRYPTO_XCFW/ios-arm64-simulator/libcrypto.a" "$IOS_OPENSSL_SIM/lib/" -echo "Creating universal binary..." +build_slice "ios-arm64" "iphoneos" "arm64" "aarch64-apple-darwin" "-mios-version-min=${IOS_DEPLOYMENT_TARGET}" "$IOS_OPENSSL_DEVICE" +build_slice "ios-arm64-simulator" "iphonesimulator" "arm64" "aarch64-apple-darwin" "-mios-simulator-version-min=${IOS_DEPLOYMENT_TARGET}" "$IOS_OPENSSL_SIM" + +echo "==> Creating macOS universal slice..." lipo -create \ - "$LIBS_DIR/libsybdb_arm64.a" \ - "$LIBS_DIR/libsybdb_x86_64.a" \ - -output "$LIBS_DIR/libsybdb_universal.a" + "$LIBS_DIR/libsybdb_macos-arm64.a" \ + "$LIBS_DIR/libsybdb_macos-x86_64.a" \ + -output "$LIBS_DIR/libsybdb_macos_universal.a" + +HEADERS_STAGE="$BUILD_DIR/headers-stage" +rm -rf "$HEADERS_STAGE" +mkdir -p "$HEADERS_STAGE" +cp "$SOURCE_DIR/include/sybdb.h" "$HEADERS_STAGE/" +cp "$SOURCE_DIR/include/sybfront.h" "$HEADERS_STAGE/" -cp "$LIBS_DIR/libsybdb_universal.a" "$LIBS_DIR/libsybdb.a" +# Do NOT copy raw FreeTDS headers into Plugins/MSSQLDriverPlugin/CFreeTDS/include/. Those are +# hand-curated Swift-compatible stubs. Upstream sybdb.h transitively requires generated headers +# (tds_sysdep_public.h, etc.) that we don't ship. The xcframework's bundled headers are also stubs +# for consumers; the real symbols are exported by libsybdb.a at link time. -echo "Copying headers..." -cp /tmp/freetds-arm64/include/sybdb.h "$INCLUDE_DST/sybdb.h" -cp /tmp/freetds-arm64/include/sybfront.h "$INCLUDE_DST/sybfront.h" +echo "==> Assembling FreeTDS.xcframework..." +XCFRAMEWORK_OUT="$IOS_LIBS_DIR/FreeTDS.xcframework" +rm -rf "$XCFRAMEWORK_OUT" +xcodebuild -create-xcframework \ + -library "$LIBS_DIR/libsybdb_macos_universal.a" -headers "$HEADERS_STAGE" \ + -library "$LIBS_DIR/libsybdb_ios-arm64.a" -headers "$HEADERS_STAGE" \ + -library "$LIBS_DIR/libsybdb_ios-arm64-simulator.a" -headers "$HEADERS_STAGE" \ + -output "$XCFRAMEWORK_OUT" -echo "FreeTDS build complete!" -echo "Libraries in: $LIBS_DIR" -echo "Headers in: $INCLUDE_DST" +echo "==> Cleaning intermediate per-slice archives..." +rm -f \ + "$LIBS_DIR/libsybdb_macos-arm64.a" \ + "$LIBS_DIR/libsybdb_macos-x86_64.a" \ + "$LIBS_DIR/libsybdb_macos_universal.a" \ + "$LIBS_DIR/libsybdb_ios-arm64.a" \ + "$LIBS_DIR/libsybdb_ios-arm64-simulator.a" + +echo "" +echo "FreeTDS.xcframework built at: $XCFRAMEWORK_OUT" +echo "Slices:" +ls -1 "$XCFRAMEWORK_OUT" | grep -v Info.plist | sed 's/^/ - /' echo "" echo "NEXT STEPS:" -echo " 1. Add the CFreeTDS module to Xcode project" -echo " 2. Add libsybdb.a to Link Binary With Libraries" -echo " 3. Add CFreeTDS/include/ to Header Search Paths" +echo " 1. Inspect: xcodebuild -checkFirstLaunchStatus; file ${XCFRAMEWORK_OUT}/*/libsybdb.a" +echo " 2. Re-pack iOS libs archive and upload to libs-v1 release:" +echo " tar czf /tmp/tablepro-libs-ios-v1.tar.gz -C ${IOS_LIBS_DIR} ." +echo " gh release upload libs-v1 /tmp/tablepro-libs-ios-v1.tar.gz --clobber --repo TableProApp/TablePro" From 30377f0f79d0a6a482c5d0f546623087c11280c2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 13 May 2026 18:21:42 +0700 Subject: [PATCH 2/5] refactor(ios-mssql): deterministic pagination + native cancellation + more tests --- .github/workflows/daily-repo-status.lock.yml | 1130 ----------------- .github/workflows/daily-repo-status.md | 58 - .../Drivers/FreeTDSConnection.swift | 16 +- .../ViewModels/DataBrowserViewModel.swift | 20 +- .../Drivers/MSSQLDriverTests.swift | 69 +- 5 files changed, 96 insertions(+), 1197 deletions(-) delete mode 100644 .github/workflows/daily-repo-status.lock.yml delete mode 100644 .github/workflows/daily-repo-status.md diff --git a/.github/workflows/daily-repo-status.lock.yml b/.github/workflows/daily-repo-status.lock.yml deleted file mode 100644 index 25e980a29..000000000 --- a/.github/workflows/daily-repo-status.lock.yml +++ /dev/null @@ -1,1130 +0,0 @@ -# -# ___ _ _ -# / _ \ | | (_) -# | |_| | __ _ ___ _ __ | |_ _ ___ -# | _ |/ _` |/ _ \ '_ \| __| |/ __| -# | | | | (_| | __/ | | | |_| | (__ -# \_| |_/\__, |\___|_| |_|\__|_|\___| -# __/ | -# _ _ |___/ -# | | | | / _| | -# | | | | ___ _ __ _ __| |_| | _____ ____ -# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| -# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ -# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ -# -# This file was automatically generated by gh-aw (v0.57.2). DO NOT EDIT. -# -# To update this file, edit githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f and run: -# gh aw compile -# Not all edits will cause changes to this file. -# -# For more information: https://github.github.com/gh-aw/introduction/overview/ -# -# This workflow creates daily repo status reports. It gathers recent repository -# activity (issues, PRs, discussions, releases, code changes) and generates -# engaging GitHub issues with productivity insights, community highlights, -# and project recommendations. -# -# Source: githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f -# -# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"1937f4c9ad5978528ec699e525271fa402d8d659376eb7287f1ebec69c681d2c","compiler_version":"v0.57.2","strict":true} - -name: "Daily Repo Status" -"on": - schedule: - - cron: "23 19 * * *" - # Friendly format: daily (scattered) - workflow_dispatch: - -permissions: {} - -concurrency: - group: "gh-aw-${{ github.workflow }}" - -run-name: "Daily Repo Status" - -jobs: - activation: - runs-on: ubuntu-slim - permissions: - contents: read - outputs: - comment_id: "" - comment_repo: "" - model: ${{ steps.generate_aw_info.outputs.model }} - secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 - with: - destination: /opt/gh-aw/actions - - name: Generate agentic run info - id: generate_aw_info - env: - GH_AW_INFO_ENGINE_ID: "copilot" - GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" - GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} - GH_AW_INFO_VERSION: "" - GH_AW_INFO_AGENT_VERSION: "latest" - GH_AW_INFO_CLI_VERSION: "v0.57.2" - GH_AW_INFO_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_INFO_EXPERIMENTAL: "false" - GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" - GH_AW_INFO_STAGED: "false" - GH_AW_INFO_ALLOWED_DOMAINS: '["defaults"]' - GH_AW_INFO_FIREWALL_ENABLED: "true" - GH_AW_INFO_AWF_VERSION: "v0.23.0" - GH_AW_INFO_AWMG_VERSION: "" - GH_AW_INFO_FIREWALL_TYPE: "squid" - GH_AW_COMPILED_STRICT: "true" - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { main } = require('/opt/gh-aw/actions/generate_aw_info.cjs'); - await main(core, context); - - name: Validate COPILOT_GITHUB_TOKEN secret - id: validate-secret - run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default - env: - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - - name: Checkout .github and .agents folders - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - sparse-checkout: | - .github - .agents - sparse-checkout-cone-mode: true - fetch-depth: 1 - - name: Check workflow file timestamps - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_WORKFLOW_FILE: "daily-repo-status.lock.yml" - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); - await main(); - - name: Create prompt with built-in context - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - run: | - bash /opt/gh-aw/actions/create_prompt_first.sh - { - cat << 'GH_AW_PROMPT_EOF' - - GH_AW_PROMPT_EOF - cat "/opt/gh-aw/prompts/xpia.md" - cat "/opt/gh-aw/prompts/temp_folder_prompt.md" - cat "/opt/gh-aw/prompts/markdown.md" - cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" - cat << 'GH_AW_PROMPT_EOF' - - Tools: create_issue, missing_tool, missing_data, noop - - - The following GitHub context information is available for this workflow: - {{#if __GH_AW_GITHUB_ACTOR__ }} - - **actor**: __GH_AW_GITHUB_ACTOR__ - {{/if}} - {{#if __GH_AW_GITHUB_REPOSITORY__ }} - - **repository**: __GH_AW_GITHUB_REPOSITORY__ - {{/if}} - {{#if __GH_AW_GITHUB_WORKSPACE__ }} - - **workspace**: __GH_AW_GITHUB_WORKSPACE__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} - - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} - - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} - - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ - {{/if}} - {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} - - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ - {{/if}} - {{#if __GH_AW_GITHUB_RUN_ID__ }} - - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ - {{/if}} - - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' - - GH_AW_PROMPT_EOF - cat << 'GH_AW_PROMPT_EOF' - {{#runtime-import .github/workflows/daily-repo-status.md}} - GH_AW_PROMPT_EOF - } > "$GH_AW_PROMPT" - - name: Interpolate variables and render templates - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); - await main(); - - name: Substitute placeholders - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_GITHUB_ACTOR: ${{ github.actor }} - GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} - GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} - GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} - GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - - const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); - - // Call the substitution function - return await substitutePlaceholders({ - file: process.env.GH_AW_PROMPT, - substitutions: { - GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, - GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, - GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, - GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, - GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, - GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, - GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, - GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE - } - }); - - name: Validate prompt placeholders - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh - - name: Print prompt - env: - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - run: bash /opt/gh-aw/actions/print_prompt_summary.sh - - name: Upload activation artifact - if: success() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 - with: - name: activation - path: | - /tmp/gh-aw/aw_info.json - /tmp/gh-aw/aw-prompts/prompt.txt - retention-days: 1 - - agent: - needs: activation - runs-on: ubuntu-latest - permissions: - contents: read - issues: read - pull-requests: read - concurrency: - group: "gh-aw-copilot-${{ github.workflow }}" - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - GH_AW_ASSETS_ALLOWED_EXTS: "" - GH_AW_ASSETS_BRANCH: "" - GH_AW_ASSETS_MAX_SIZE_KB: 0 - GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs - GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl - GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json - GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json - GH_AW_WORKFLOW_ID_SANITIZED: dailyrepostatus - outputs: - checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} - detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} - detection_success: ${{ steps.detection_conclusion.outputs.success }} - has_patch: ${{ steps.collect_output.outputs.has_patch }} - inference_access_error: ${{ steps.detect-inference-error.outputs.inference_access_error || 'false' }} - model: ${{ needs.activation.outputs.model }} - output: ${{ steps.collect_output.outputs.output }} - output_types: ${{ steps.collect_output.outputs.output_types }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 - with: - destination: /opt/gh-aw/actions - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Create gh-aw temp directory - run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git config --global am.keepcr true - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Checkout PR branch - id: checkout-pr - if: | - (github.event.pull_request) || (github.event.issue.pull_request) - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - with: - github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); - await main(); - - name: Install GitHub Copilot CLI - run: /opt/gh-aw/actions/install_copilot_cli.sh latest - - name: Install awf binary - run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.23.0 - - name: Download container images - run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.23.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.23.0 ghcr.io/github/gh-aw-firewall/squid:0.23.0 ghcr.io/github/gh-aw-mcpg:v0.1.8 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine - - name: Write Safe Outputs Config - run: | - mkdir -p /opt/gh-aw/safeoutputs - mkdir -p /tmp/gh-aw/safeoutputs - mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs - cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' - {"create_issue":{"max":1},"mentions":{"enabled":false},"missing_data":{},"missing_tool":{},"noop":{"max":1}} - GH_AW_SAFE_OUTPUTS_CONFIG_EOF - cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF' - [ - { - "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[repo-status] \". Labels [\"report\" \"daily-status\"] will be automatically added.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "body": { - "description": "Detailed issue description in Markdown. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate.", - "type": "string" - }, - "integrity": { - "description": "Trustworthiness level of the message source (e.g., \"low\", \"medium\", \"high\").", - "type": "string" - }, - "labels": { - "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository.", - "items": { - "type": "string" - }, - "type": "array" - }, - "parent": { - "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id (e.g., 'aw_abc123', 'aw_Test123') from a previously created issue in the same workflow run.", - "type": [ - "number", - "string" - ] - }, - "secrecy": { - "description": "Confidentiality level of the message content (e.g., \"public\", \"internal\", \"private\").", - "type": "string" - }, - "temporary_id": { - "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 3 to 12 alphanumeric characters (e.g., 'aw_abc1', 'aw_Test123'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.", - "pattern": "^aw_[A-Za-z0-9]{3,12}$", - "type": "string" - }, - "title": { - "description": "Concise issue title summarizing the bug, feature, or task. The title appears as the main heading, so keep it brief and descriptive.", - "type": "string" - } - }, - "required": [ - "title", - "body" - ], - "type": "object" - }, - "name": "create_issue" - }, - { - "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "alternatives": { - "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", - "type": "string" - }, - "integrity": { - "description": "Trustworthiness level of the message source (e.g., \"low\", \"medium\", \"high\").", - "type": "string" - }, - "reason": { - "description": "Explanation of why this tool is needed or what information you want to share about the limitation (max 256 characters).", - "type": "string" - }, - "secrecy": { - "description": "Confidentiality level of the message content (e.g., \"public\", \"internal\", \"private\").", - "type": "string" - }, - "tool": { - "description": "Optional: Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.", - "type": "string" - } - }, - "required": [ - "reason" - ], - "type": "object" - }, - "name": "missing_tool" - }, - { - "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "integrity": { - "description": "Trustworthiness level of the message source (e.g., \"low\", \"medium\", \"high\").", - "type": "string" - }, - "message": { - "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').", - "type": "string" - }, - "secrecy": { - "description": "Confidentiality level of the message content (e.g., \"public\", \"internal\", \"private\").", - "type": "string" - } - }, - "required": [ - "message" - ], - "type": "object" - }, - "name": "noop" - }, - { - "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", - "inputSchema": { - "additionalProperties": false, - "properties": { - "alternatives": { - "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", - "type": "string" - }, - "context": { - "description": "Additional context about the missing data or where it should come from (max 256 characters).", - "type": "string" - }, - "data_type": { - "description": "Type or description of the missing data or information (max 128 characters). Be specific about what data is needed.", - "type": "string" - }, - "integrity": { - "description": "Trustworthiness level of the message source (e.g., \"low\", \"medium\", \"high\").", - "type": "string" - }, - "reason": { - "description": "Explanation of why this data is needed to complete the task (max 256 characters).", - "type": "string" - }, - "secrecy": { - "description": "Confidentiality level of the message content (e.g., \"public\", \"internal\", \"private\").", - "type": "string" - } - }, - "required": [], - "type": "object" - }, - "name": "missing_data" - } - ] - GH_AW_SAFE_OUTPUTS_TOOLS_EOF - cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' - { - "create_issue": { - "defaultMax": 1, - "fields": { - "body": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - }, - "labels": { - "type": "array", - "itemType": "string", - "itemSanitize": true, - "itemMaxLength": 128 - }, - "parent": { - "issueOrPRNumber": true - }, - "repo": { - "type": "string", - "maxLength": 256 - }, - "temporary_id": { - "type": "string" - }, - "title": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 128 - } - } - }, - "missing_data": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "context": { - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "data_type": { - "type": "string", - "sanitize": true, - "maxLength": 128 - }, - "reason": { - "type": "string", - "sanitize": true, - "maxLength": 256 - } - } - }, - "missing_tool": { - "defaultMax": 20, - "fields": { - "alternatives": { - "type": "string", - "sanitize": true, - "maxLength": 512 - }, - "reason": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 256 - }, - "tool": { - "type": "string", - "sanitize": true, - "maxLength": 128 - } - } - }, - "noop": { - "defaultMax": 1, - "fields": { - "message": { - "required": true, - "type": "string", - "sanitize": true, - "maxLength": 65000 - } - } - } - } - GH_AW_SAFE_OUTPUTS_VALIDATION_EOF - - name: Generate Safe Outputs MCP Server Config - id: safe-outputs-config - run: | - # Generate a secure random API key (360 bits of entropy, 40+ chars) - # Mask immediately to prevent timing vulnerabilities - API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "::add-mask::${API_KEY}" - - PORT=3001 - - # Set outputs for next steps - { - echo "safe_outputs_api_key=${API_KEY}" - echo "safe_outputs_port=${PORT}" - } >> "$GITHUB_OUTPUT" - - echo "Safe Outputs MCP server will run on port ${PORT}" - - - name: Start Safe Outputs MCP HTTP Server - id: safe-outputs-start - env: - DEBUG: '*' - GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} - GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} - GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json - GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json - GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs - run: | - # Environment variables are set above to prevent template injection - export DEBUG - export GH_AW_SAFE_OUTPUTS_PORT - export GH_AW_SAFE_OUTPUTS_API_KEY - export GH_AW_SAFE_OUTPUTS_TOOLS_PATH - export GH_AW_SAFE_OUTPUTS_CONFIG_PATH - export GH_AW_MCP_LOG_DIR - - bash /opt/gh-aw/actions/start_safe_outputs_server.sh - - - name: Start MCP Gateway - id: start-mcp-gateway - env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} - GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - run: | - set -eo pipefail - mkdir -p /tmp/gh-aw/mcp-config - - # Export gateway environment variables for MCP config and gateway script - export MCP_GATEWAY_PORT="80" - export MCP_GATEWAY_DOMAIN="host.docker.internal" - MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - echo "::add-mask::${MCP_GATEWAY_API_KEY}" - export MCP_GATEWAY_API_KEY - export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" - mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" - export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" - export DEBUG="*" - - export GH_AW_ENGINE="copilot" - export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.8' - - mkdir -p /home/runner/.copilot - cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh - { - "mcpServers": { - "github": { - "type": "stdio", - "container": "ghcr.io/github/github-mcp-server:v0.32.0", - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", - "GITHUB_READ_ONLY": "1", - "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" - } - }, - "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", - "headers": { - "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" - } - } - }, - "gateway": { - "port": $MCP_GATEWAY_PORT, - "domain": "${MCP_GATEWAY_DOMAIN}", - "apiKey": "${MCP_GATEWAY_API_KEY}", - "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" - } - } - GH_AW_MCP_CONFIG_EOF - - name: Download activation artifact - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8 - with: - name: activation - path: /tmp/gh-aw - - name: Clean git credentials - run: bash /opt/gh-aw/actions/clean_git_credentials.sh - - name: Execute GitHub Copilot CLI - id: agentic_execution - # Copilot CLI tool arguments (sorted): - timeout-minutes: 20 - run: | - set -o pipefail - touch /tmp/gh-aw/agent-step-summary.md - # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log - env: - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} - GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json - GH_AW_PHASE: agent - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_VERSION: v0.57.2 - GITHUB_API_URL: ${{ github.api_url }} - GITHUB_AW: true - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md - GITHUB_WORKSPACE: ${{ github.workspace }} - GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com - GIT_AUTHOR_NAME: github-actions[bot] - GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com - GIT_COMMITTER_NAME: github-actions[bot] - XDG_CONFIG_HOME: /home/runner - - name: Detect inference access error - id: detect-inference-error - if: always() - continue-on-error: true - run: bash /opt/gh-aw/actions/detect_inference_access_error.sh - - name: Configure Git credentials - env: - REPO_NAME: ${{ github.repository }} - SERVER_URL: ${{ github.server_url }} - run: | - git config --global user.email "github-actions[bot]@users.noreply.github.com" - git config --global user.name "github-actions[bot]" - git config --global am.keepcr true - # Re-authenticate git with GitHub token - SERVER_URL_STRIPPED="${SERVER_URL#https://}" - git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" - echo "Git configured with standard GitHub Actions identity" - - name: Copy Copilot session state files to logs - if: always() - continue-on-error: true - run: | - # Copy Copilot session state files to logs folder for artifact collection - # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them - SESSION_STATE_DIR="$HOME/.copilot/session-state" - LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" - - if [ -d "$SESSION_STATE_DIR" ]; then - echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" - mkdir -p "$LOGS_DIR" - cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true - echo "Session state files copied successfully" - else - echo "No session-state directory found at $SESSION_STATE_DIR" - fi - - name: Stop MCP Gateway - if: always() - continue-on-error: true - env: - MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} - MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} - GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} - run: | - bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" - - name: Redact secrets in logs - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); - await main(); - env: - GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' - SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} - SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Append agent step summary - if: always() - run: bash /opt/gh-aw/actions/append_agent_step_summary.sh - - name: Upload Safe Outputs - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 - with: - name: safe-output - path: ${{ env.GH_AW_SAFE_OUTPUTS }} - if-no-files-found: warn - - name: Ingest agent output - id: collect_output - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} - GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" - GH_AW_ALLOWED_GITHUB_REFS: "" - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_API_URL: ${{ github.api_url }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/collect_ndjson_output.cjs'); - await main(); - - name: Upload sanitized agent output - if: always() && env.GH_AW_AGENT_OUTPUT - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 - with: - name: agent-output - path: ${{ env.GH_AW_AGENT_OUTPUT }} - if-no-files-found: warn - - name: Upload engine output files - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 - with: - name: agent_outputs - path: | - /tmp/gh-aw/sandbox/agent/logs/ - /tmp/gh-aw/redacted-urls.log - if-no-files-found: ignore - - name: Parse agent logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_copilot_log.cjs'); - await main(); - - name: Parse MCP Gateway logs for step summary - if: always() - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); - await main(); - - name: Print firewall logs - if: always() - continue-on-error: true - env: - AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs - run: | - # Fix permissions on firewall logs so they can be uploaded as artifacts - # AWF runs with sudo, creating files owned by root - sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true - # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) - if command -v awf &> /dev/null; then - awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" - else - echo 'AWF binary not installed, skipping firewall log summary' - fi - - name: Upload agent artifacts - if: always() - continue-on-error: true - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 - with: - name: agent-artifacts - path: | - /tmp/gh-aw/aw-prompts/prompt.txt - /tmp/gh-aw/mcp-logs/ - /tmp/gh-aw/sandbox/firewall/logs/ - /tmp/gh-aw/agent-stdio.log - /tmp/gh-aw/agent/ - if-no-files-found: ignore - # --- Threat Detection (inline) --- - - name: Check if detection needed - id: detection_guard - if: always() - env: - OUTPUT_TYPES: ${{ steps.collect_output.outputs.output_types }} - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} - run: | - if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then - echo "run_detection=true" >> "$GITHUB_OUTPUT" - echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" - else - echo "run_detection=false" >> "$GITHUB_OUTPUT" - echo "Detection skipped: no agent outputs or patches to analyze" - fi - - name: Clear MCP configuration for detection - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - rm -f /tmp/gh-aw/mcp-config/mcp-servers.json - rm -f /home/runner/.copilot/mcp-config.json - rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" - - name: Prepare threat detection files - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - mkdir -p /tmp/gh-aw/threat-detection/aw-prompts - cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true - cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true - for f in /tmp/gh-aw/aw-*.patch; do - [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true - done - echo "Prepared threat detection files:" - ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true - - name: Setup threat detection - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - WORKFLOW_NAME: "Daily Repo Status" - WORKFLOW_DESCRIPTION: "This workflow creates daily repo status reports. It gathers recent repository\nactivity (issues, PRs, discussions, releases, code changes) and generates\nengaging GitHub issues with productivity insights, community highlights,\nand project recommendations." - HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs'); - await main(); - - name: Ensure threat-detection directory and log - if: always() && steps.detection_guard.outputs.run_detection == 'true' - run: | - mkdir -p /tmp/gh-aw/threat-detection - touch /tmp/gh-aw/threat-detection/detection.log - - name: Execute GitHub Copilot CLI - if: always() && steps.detection_guard.outputs.run_detection == 'true' - id: detection_agentic_execution - # Copilot CLI tool arguments (sorted): - # --allow-tool shell(cat) - # --allow-tool shell(grep) - # --allow-tool shell(head) - # --allow-tool shell(jq) - # --allow-tool shell(ls) - # --allow-tool shell(tail) - # --allow-tool shell(wc) - timeout-minutes: 20 - run: | - set -o pipefail - touch /tmp/gh-aw/agent-step-summary.md - # shellcheck disable=SC1003 - sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \ - -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(wc)'\'' --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log - env: - COPILOT_AGENT_RUNNER_TYPE: STANDALONE - COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} - COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} - GH_AW_PHASE: detection - GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt - GH_AW_VERSION: v0.57.2 - GITHUB_API_URL: ${{ github.api_url }} - GITHUB_AW: true - GITHUB_HEAD_REF: ${{ github.head_ref }} - GITHUB_REF_NAME: ${{ github.ref_name }} - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md - GITHUB_WORKSPACE: ${{ github.workspace }} - GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com - GIT_AUTHOR_NAME: github-actions[bot] - GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com - GIT_COMMITTER_NAME: github-actions[bot] - XDG_CONFIG_HOME: /home/runner - - name: Parse threat detection results - id: parse_detection_results - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs'); - await main(); - - name: Upload threat detection log - if: always() && steps.detection_guard.outputs.run_detection == 'true' - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 - with: - name: threat-detection.log - path: /tmp/gh-aw/threat-detection/detection.log - if-no-files-found: ignore - - name: Set detection conclusion - id: detection_conclusion - if: always() - env: - RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} - DETECTION_SUCCESS: ${{ steps.parse_detection_results.outputs.success }} - run: | - if [[ "$RUN_DETECTION" != "true" ]]; then - echo "conclusion=skipped" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection was not needed, marking as skipped" - elif [[ "$DETECTION_SUCCESS" == "true" ]]; then - echo "conclusion=success" >> "$GITHUB_OUTPUT" - echo "success=true" >> "$GITHUB_OUTPUT" - echo "Detection passed successfully" - else - echo "conclusion=failure" >> "$GITHUB_OUTPUT" - echo "success=false" >> "$GITHUB_OUTPUT" - echo "Detection found issues" - fi - - conclusion: - needs: - - activation - - agent - - safe_outputs - if: (always()) && (needs.agent.result != 'skipped') - runs-on: ubuntu-slim - permissions: - contents: read - issues: write - concurrency: - group: "gh-aw-conclusion-daily-repo-status" - cancel-in-progress: false - outputs: - noop_message: ${{ steps.noop.outputs.noop_message }} - tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} - total_count: ${{ steps.missing_tool.outputs.total_count }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 - with: - destination: /opt/gh-aw/actions - - name: Download agent output artifact - id: download-agent-output - continue-on-error: true - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8 - with: - name: agent-output - path: /tmp/gh-aw/safeoutputs/ - - name: Setup agent output environment variable - if: steps.download-agent-output.outcome == 'success' - run: | - mkdir -p /tmp/gh-aw/safeoutputs/ - find "/tmp/gh-aw/safeoutputs/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Process No-Op Messages - id: noop - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_NOOP_MAX: "1" - GH_AW_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/346204513ecfa08b81566450d7d599556807389f/workflows/daily-repo-status.md" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/noop.cjs'); - await main(); - - name: Record Missing Tool - id: missing_tool - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/346204513ecfa08b81566450d7d599556807389f/workflows/daily-repo-status.md" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/missing_tool.cjs'); - await main(); - - name: Handle Agent Failure - id: handle_agent_failure - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/346204513ecfa08b81566450d7d599556807389f/workflows/daily-repo-status.md" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_WORKFLOW_ID: "daily-repo-status" - GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} - GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} - GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} - GH_AW_GROUP_REPORTS: "false" - GH_AW_FAILURE_REPORT_AS_ISSUE: "true" - GH_AW_TIMEOUT_MINUTES: "20" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/handle_agent_failure.cjs'); - await main(); - - name: Handle No-Op Message - id: handle_noop_message - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/346204513ecfa08b81566450d7d599556807389f/workflows/daily-repo-status.md" - GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} - GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} - GH_AW_NOOP_REPORT_AS_ISSUE: "true" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs'); - await main(); - - safe_outputs: - needs: agent - if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') - runs-on: ubuntu-slim - permissions: - contents: read - issues: write - timeout-minutes: 15 - env: - GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/daily-repo-status" - GH_AW_ENGINE_ID: "copilot" - GH_AW_WORKFLOW_ID: "daily-repo-status" - GH_AW_WORKFLOW_NAME: "Daily Repo Status" - GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f" - GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/346204513ecfa08b81566450d7d599556807389f/workflows/daily-repo-status.md" - outputs: - code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} - code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} - create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} - create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} - created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }} - created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }} - process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} - process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} - steps: - - name: Setup Scripts - uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 - with: - destination: /opt/gh-aw/actions - - name: Download agent output artifact - id: download-agent-output - continue-on-error: true - uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8 - with: - name: agent-output - path: /tmp/gh-aw/safeoutputs/ - - name: Setup agent output environment variable - if: steps.download-agent-output.outcome == 'success' - run: | - mkdir -p /tmp/gh-aw/safeoutputs/ - find "/tmp/gh-aw/safeoutputs/" -type f -print - echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" - - name: Process Safe Outputs - id: process_safe_outputs - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - env: - GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} - GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" - GITHUB_SERVER_URL: ${{ github.server_url }} - GITHUB_API_URL: ${{ github.api_url }} - GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"close_older_issues\":true,\"labels\":[\"report\",\"daily-status\"],\"max\":1,\"title_prefix\":\"[repo-status] \"},\"missing_data\":{},\"missing_tool\":{}}" - with: - github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io); - const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); - await main(); - - name: Upload safe output items manifest - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 - with: - name: safe-output-items - path: /tmp/safe-output-items.jsonl - if-no-files-found: warn - diff --git a/.github/workflows/daily-repo-status.md b/.github/workflows/daily-repo-status.md deleted file mode 100644 index 5ab7aafe3..000000000 --- a/.github/workflows/daily-repo-status.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -description: | - This workflow creates daily repo status reports. It gathers recent repository - activity (issues, PRs, discussions, releases, code changes) and generates - engaging GitHub issues with productivity insights, community highlights, - and project recommendations. - -on: - schedule: daily - workflow_dispatch: - -permissions: - contents: read - issues: read - pull-requests: read - -network: defaults - -tools: - github: - # If in a public repo, setting `lockdown: false` allows - # reading issues, pull requests and comments from 3rd-parties - # If in a private repo this has no particular effect. - lockdown: false - -safe-outputs: - mentions: false - allowed-github-references: [] - create-issue: - title-prefix: "[repo-status] " - labels: [report, daily-status] - close-older-issues: true -source: githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f -engine: copilot ---- - -# Daily Repo Status - -Create an upbeat daily status report for the repo as a GitHub issue. - -## What to include - -- Recent repository activity (issues, PRs, discussions, releases, code changes) -- Progress tracking, goal reminders and highlights -- Project status and recommendations -- Actionable next steps for maintainers - -## Style - -- Be positive, encouraging, and helpful 🌟 -- Use emojis moderately for engagement -- Keep it concise - adjust length based on actual activity - -## Process - -1. Gather recent activity from the repository -2. Study the repository, its issues and its pull requests -3. Create a new GitHub issue with your findings and insights \ No newline at end of file diff --git a/TableProMobile/TableProMobile/Drivers/FreeTDSConnection.swift b/TableProMobile/TableProMobile/Drivers/FreeTDSConnection.swift index 5b315b945..67ddca560 100644 --- a/TableProMobile/TableProMobile/Drivers/FreeTDSConnection.swift +++ b/TableProMobile/TableProMobile/Drivers/FreeTDSConnection.swift @@ -202,8 +202,12 @@ final class FreeTDSConnection: @unchecked Sendable { func executeQuery(_ query: String) async throws -> MSSQLRawResult { let queryToRun = String(query) - return try await freetdsDispatchAsync(on: queue) { [self] in - try self.executeQuerySync(queryToRun) + return try await withTaskCancellationHandler { + try await freetdsDispatchAsync(on: queue) { [self] in + try self.executeQuerySync(queryToRun) + } + } onCancel: { [weak self] in + self?.cancelCurrentQuery() } } @@ -319,8 +323,12 @@ final class FreeTDSConnection: @unchecked Sendable { continuation: AsyncThrowingStream.Continuation ) async throws { let queryToRun = String(query) - try await freetdsDispatchAsync(on: queue) { [self] in - try self.streamQuerySync(queryToRun, continuation: continuation) + try await withTaskCancellationHandler { + try await freetdsDispatchAsync(on: queue) { [self] in + try self.streamQuerySync(queryToRun, continuation: continuation) + } + } onCancel: { [weak self] in + self?.cancelCurrentQuery() } } diff --git a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift index b9d0986c1..0c36a5220 100644 --- a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -157,12 +157,13 @@ final class DataBrowserViewModel { } private func buildSelectQuery(table: TableInfo) -> String { + let activeSort = effectiveSortState() if hasActiveSearch { return SQLBuilder.buildSearchSelect( table: table.name, type: databaseType, searchText: activeSearchText, searchColumns: searchableColumns(), filters: filters, logicMode: filterLogicMode, - sortState: sortState, + sortState: activeSort, limit: pagination.pageSize, offset: pagination.currentOffset ) } @@ -170,14 +171,14 @@ final class DataBrowserViewModel { return SQLBuilder.buildFilteredSelect( table: table.name, type: databaseType, filters: filters, logicMode: filterLogicMode, - sortState: sortState, + sortState: activeSort, limit: pagination.pageSize, offset: pagination.currentOffset ) } - if sortState.isSorting { + if activeSort.isSorting { return SQLBuilder.buildSelect( table: table.name, type: databaseType, - sortState: sortState, + sortState: activeSort, limit: pagination.pageSize, offset: pagination.currentOffset ) } @@ -187,6 +188,17 @@ final class DataBrowserViewModel { ) } + /// SQL Server's `OFFSET FETCH` requires `ORDER BY`. When the user has not picked an explicit + /// sort, inject a stable order (first primary-key column, falling back to the first column) + /// so paging is deterministic across requests. Other databases tolerate an empty ORDER BY. + private func effectiveSortState() -> SortState { + if sortState.isSorting { return sortState } + guard databaseType == .mssql else { return sortState } + let fallback = columnDetails.first(where: \.isPrimaryKey)?.name ?? columnDetails.first?.name + guard let fallback else { return sortState } + return SortState(columns: [SortColumn(name: fallback, ascending: true)]) + } + private func searchableColumns() -> [ColumnInfo] { columns.filter { col in let upper = col.typeName.uppercased() diff --git a/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift b/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift index 30450f3d4..7b38d6e4b 100644 --- a/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift +++ b/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift @@ -18,7 +18,7 @@ final class MSSQLDriverTests: XCTestCase { if env["MSSQL_TEST_HOST"] != nil { return env } - let fallbackPath = "/tmp/mssql-test.json" + let fallbackPath = env["MSSQL_TEST_CONFIG_PATH"] ?? "/tmp/mssql-test.json" guard let data = FileManager.default.contents(atPath: fallbackPath), let json = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { return nil } @@ -125,4 +125,71 @@ final class MSSQLDriverTests: XCTestCase { let result = try await driver.execute(query: "SELECT COUNT(*) FROM dbo.tablepro_tx_test") XCTAssertEqual(result.rows.first?.first, "0") } + + func testSwitchDatabaseChangesActiveDatabase() async throws { + let driver = try XCTUnwrap(driver) + try await driver.switchDatabase(to: "tempdb") + let result = try await driver.execute(query: "SELECT DB_NAME()") + XCTAssertEqual(result.rows.first?.first, "tempdb") + } + + func testSortedPaginationEmitsOrderByAndFetch() async throws { + let driver = try XCTUnwrap(driver) + _ = try await driver.execute(query: """ + IF OBJECT_ID('dbo.tablepro_pagination_test', 'U') IS NOT NULL + DROP TABLE dbo.tablepro_pagination_test; + CREATE TABLE dbo.tablepro_pagination_test (id INT PRIMARY KEY, label NVARCHAR(20)); + INSERT INTO dbo.tablepro_pagination_test VALUES + (1, N'a'), (2, N'b'), (3, N'c'), (4, N'd'), (5, N'e'); + """) + let result = try await driver.execute(query: """ + SELECT id, label FROM dbo.tablepro_pagination_test + ORDER BY id ASC + OFFSET 2 ROWS FETCH NEXT 2 ROWS ONLY + """) + XCTAssertEqual(result.rows.count, 2) + XCTAssertEqual(result.rows.first?.first, "3") + XCTAssertEqual(result.rows.last?.first, "4") + } + + func testCancellationStopsQuery() async throws { + let driver = try XCTUnwrap(driver) + let task = Task { [driver] in + try await driver.execute(query: "WAITFOR DELAY '00:00:05'; SELECT 1") + } + try await Task.sleep(nanoseconds: 100_000_000) + task.cancel() + do { + _ = try await task.value + XCTFail("Cancelled task should have thrown") + } catch is CancellationError { + // expected + } catch { + // FreeTDS may surface as DatabaseError on race; both are acceptable signals that the + // query was interrupted rather than completing the 5-second WAITFOR. + XCTAssertNotNil(error) + } + } + + func testWrongPasswordFailsClearly() async throws { + try XCTSkipIf(Self.loadTestConfig() == nil, "no test config") + let connection = DatabaseConnection( + name: "wrong-pw", + type: .mssql, + host: "localhost", + port: 1433, + username: "sa", + database: "master", + additionalFields: ["mssqlSchema": "dbo"] + ) + let badDriver = MSSQLDriver(connection: connection, password: "definitely-wrong-pass-123") + do { + try await badDriver.connect() + XCTFail("Bad password should fail to connect") + } catch { + // any error is acceptable; verify it's not nil and surfaces a message + let desc = (error as? LocalizedError)?.errorDescription ?? "\(error)" + XCTAssertFalse(desc.isEmpty) + } + } } From 1f16cf30743c4fce383ce6ec8fe6d65a35636f75 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 13 May 2026 18:41:21 +0700 Subject: [PATCH 3/5] refactor(mssql)!: share FreeTDSConnection desktop+iOS, MSSQL SSL picker, more tests --- .../FreeTDSConnection.swift | 0 Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 737 +++--------------- TablePro.xcodeproj/project.pbxproj | 10 + .../TableProMobile.xcodeproj/project.pbxproj | 3 + .../ViewModels/ConnectionFormViewModel.swift | 7 +- .../Views/ConnectionFormView.swift | 11 +- .../Drivers/MSSQLDriverTests.swift | 52 ++ 7 files changed, 178 insertions(+), 642 deletions(-) rename {TableProMobile/TableProMobile/Drivers => Plugins/MSSQLDriverPlugin}/FreeTDSConnection.swift (100%) diff --git a/TableProMobile/TableProMobile/Drivers/FreeTDSConnection.swift b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift similarity index 100% rename from TableProMobile/TableProMobile/Drivers/FreeTDSConnection.swift rename to Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 4acc0c9b8..96583bd98 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -3,11 +3,53 @@ // TablePro // -import CFreeTDS import Foundation import os +import TableProMSSQLCore import TableProPluginKit +// MARK: - Core ↔ Plugin Bridges + +private extension MSSQLRawCell { + var asPluginCell: PluginCellValue { + switch self { + case .null: return .null + case .string(let s): return PluginCellValue.fromOptional(s) + case .bytes(let d): return .bytes(d) + } + } +} + +private extension MSSQLRawResult { + func toPluginResult(executionTime: TimeInterval) -> PluginQueryResult { + PluginQueryResult( + columns: columns.map { $0.name }, + columnTypeNames: columns.map { $0.type.canonicalName }, + rows: rows.map { row in row.map { $0.asPluginCell } }, + rowsAffected: affectedRows, + executionTime: executionTime, + isTruncated: isTruncated + ) + } +} + +private extension MSSQLPluginError { + init(coreError: MSSQLCoreError) { + switch coreError { + case .notConnected: + self = .notConnected + case .connectionFailed(let m): + self = .connectionFailed(m) + case .queryFailed(let m): + self = .queryFailed(m) + case .cancelled: + self = .queryFailed(String(localized: "Query was cancelled")) + case .tlsHandshakeFailed(let m): + self = .connectionFailed(String(format: String(localized: "TLS: %@"), m)) + } + } +} + final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let pluginName = "MSSQL Driver" static let pluginVersion = "1.0.0" @@ -101,626 +143,6 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { } } -// MARK: - Global FreeTDS initialization - -/// Per-connection error storage keyed by DBPROCESS pointer. -/// Falls back to a global error string when the DBPROCESS is nil (pre-connection errors). -private let freetdsErrorLock = NSLock() -private var freetdsConnectionErrors: [UnsafeRawPointer: String] = [:] -private var freetdsGlobalError = "" - -private func freetdsGetError(for dbproc: UnsafeMutablePointer?) -> String { - freetdsErrorLock.lock() - defer { freetdsErrorLock.unlock() } - if let dbproc { - return freetdsConnectionErrors[UnsafeRawPointer(dbproc)] ?? freetdsGlobalError - } - return freetdsGlobalError -} - -private func freetdsClearError(for dbproc: UnsafeMutablePointer?) { - freetdsErrorLock.lock() - defer { freetdsErrorLock.unlock() } - if let dbproc { - freetdsConnectionErrors[UnsafeRawPointer(dbproc)] = nil - } else { - freetdsGlobalError = "" - } -} - -private func freetdsSetError(_ msg: String, for dbproc: UnsafeMutablePointer?, overwrite: Bool = false) { - freetdsErrorLock.lock() - defer { freetdsErrorLock.unlock() } - if let dbproc { - let key = UnsafeRawPointer(dbproc) - if overwrite || (freetdsConnectionErrors[key]?.isEmpty ?? true) { - freetdsConnectionErrors[key] = msg - } - } else if overwrite || freetdsGlobalError.isEmpty { - freetdsGlobalError = msg - } -} - -private func freetdsUnregister(_ dbproc: UnsafeMutablePointer) { - freetdsErrorLock.lock() - defer { freetdsErrorLock.unlock() } - freetdsConnectionErrors.removeValue(forKey: UnsafeRawPointer(dbproc)) -} - -private let freetdsLogger = Logger(subsystem: "com.TablePro", category: "FreeTDSConnection") - -private let freetdsInitOnce: Void = { - _ = dbinit() - _ = dberrhandle { dbproc, _, dberr, _, dberrstr, oserrstr in - var msg = "db-lib error \(dberr)" - if let s = dberrstr { msg += ": \(String(cString: s))" } - if let s = oserrstr, String(cString: s) != "Success" { msg += " (os: \(String(cString: s)))" } - freetdsLogger.error("FreeTDS: \(msg)") - freetdsSetError(msg, for: dbproc) - return INT_CANCEL - } - _ = dbmsghandle { dbproc, msgno, _, severity, msgtext, _, _, _ in - guard let text = msgtext else { return 0 } - let msg = String(cString: text) - if severity > 10 { - // SQL Server sends informational messages first, error messages last — - // overwrite so the most specific error is kept - freetdsSetError(msg, for: dbproc, overwrite: true) - freetdsLogger.error("FreeTDS msg \(msgno) sev \(severity): \(msg)") - } else { - freetdsLogger.debug("FreeTDS msg \(msgno): \(msg)") - } - return 0 - } -}() - -// MARK: - FreeTDS Connection - -private struct FreeTDSQueryResult { - let columns: [String] - let columnTypeNames: [String] - let rows: [[PluginCellValue]] - let affectedRows: Int - let isTruncated: Bool -} - -private final class FreeTDSConnection: @unchecked Sendable { - private var dbproc: UnsafeMutablePointer? - private let queue: DispatchQueue - private let host: String - private let port: Int - private let user: String - private let password: String - private let database: String - private let ssl: SSLConfiguration - private let lock = NSLock() - private var _isConnected = false - private var _isCancelled = false - - var isConnected: Bool { - lock.lock() - defer { lock.unlock() } - return _isConnected - } - - init( - host: String, - port: Int, - user: String, - password: String, - database: String, - ssl: SSLConfiguration - ) { - self.queue = DispatchQueue(label: "com.TablePro.freetds.\(host).\(port)", qos: .userInitiated) - self.host = host - self.port = port - self.user = user - self.password = password - self.database = database - self.ssl = ssl - _ = freetdsInitOnce - } - - func connect() async throws { - try await pluginDispatchAsync(on: queue) { [self] in - try self.connectSync() - } - } - - private func connectSync() throws { - guard let login = dblogin() else { - throw MSSQLPluginError.connectionFailed("Failed to create login") - } - defer { dbloginfree(login) } - - _ = dbsetlname(login, user, Int32(DBSETUSER)) - _ = dbsetlname(login, password, Int32(DBSETPWD)) - _ = dbsetlname(login, "TablePro", Int32(DBSETAPP)) - _ = dbsetlname(login, "us_english", Int32(DBSETNATLANG)) - _ = dbsetlname(login, "UTF-8", Int32(DBSETCHARSET)) - _ = dbsetlversion(login, UInt8(DBVERSION_74)) - _ = dbsetlname(login, MSSQLSSLMapping.freetdsEncryptionFlag(for: ssl.mode), Int32(DBSETENCRYPT)) - - freetdsClearError(for: nil) - let serverName = "\(host):\(port)" - guard let proc = dbopen(login, serverName) else { - let detail = freetdsGetError(for: nil) - let msg = detail.isEmpty ? "Check host, port, and credentials" : detail - throw MSSQLPluginError.connectionFailed("Failed to connect to \(host):\(port) — \(msg)") - } - - if !database.isEmpty { - if dbuse(proc, database) == FAIL { - _ = dbclose(proc) - throw MSSQLPluginError.connectionFailed("Cannot open database '\(database)'") - } - } - - self.dbproc = proc - lock.lock() - _isConnected = true - lock.unlock() - } - - func switchDatabase(_ database: String) async throws { - try await pluginDispatchAsync(on: queue) { [self] in - guard let proc = self.dbproc else { - throw MSSQLPluginError.notConnected - } - if dbuse(proc, database) == FAIL { - throw MSSQLPluginError.queryFailed("Cannot switch to database '\(database)'") - } - } - } - - func disconnect() { - let handle = dbproc - dbproc = nil - - lock.lock() - _isConnected = false - lock.unlock() - - if let handle = handle { - freetdsUnregister(handle) - queue.async { - _ = dbclose(handle) - } - } - } - - func cancelCurrentQuery() { - lock.lock() - _isCancelled = true - let proc = dbproc - lock.unlock() - - guard let proc else { return } - dbcancel(proc) - } - - func executeQuery(_ query: String) async throws -> FreeTDSQueryResult { - let queryToRun = String(query) - return try await pluginDispatchAsync(on: queue) { [self] in - try self.executeQuerySync(queryToRun) - } - } - - private func executeQuerySync(_ query: String) throws -> FreeTDSQueryResult { - guard let proc = dbproc else { - throw MSSQLPluginError.notConnected - } - - _ = dbcanquery(proc) - - lock.lock() - _isCancelled = false - lock.unlock() - - freetdsClearError(for: proc) - if dbcmd(proc, query) == FAIL { - throw MSSQLPluginError.queryFailed("Failed to prepare query") - } - if dbsqlexec(proc) == FAIL { - let detail = freetdsGetError(for: proc) - let msg = detail.isEmpty ? "Query execution failed" : detail - throw MSSQLPluginError.queryFailed(msg) - } - - var allColumns: [String] = [] - var allTypeNames: [String] = [] - var allRows: [[PluginCellValue]] = [] - var firstResultSet = true - var truncated = false - - while true { - lock.lock() - let cancelledBetweenResults = _isCancelled - if cancelledBetweenResults { _isCancelled = false } - lock.unlock() - if cancelledBetweenResults { - throw CancellationError() - } - - let resCode = dbresults(proc) - if resCode == FAIL { - throw MSSQLPluginError.queryFailed("Query execution failed") - } - if resCode == Int32(NO_MORE_RESULTS) { - break - } - - let numCols = dbnumcols(proc) - if numCols <= 0 { continue } - - var cols: [String] = [] - var typeNames: [String] = [] - for i in 1...numCols { - let name = dbcolname(proc, Int32(i)).map { String(cString: $0) } ?? "col\(i)" - cols.append(name) - typeNames.append(Self.freetdsTypeName(dbcoltype(proc, Int32(i)))) - } - - if firstResultSet { - allColumns = cols - allTypeNames = typeNames - firstResultSet = false - } - - while true { - let rowCode = dbnextrow(proc) - if rowCode == Int32(NO_MORE_ROWS) { break } - if rowCode == FAIL { break } - - lock.lock() - let cancelled = _isCancelled - if cancelled { _isCancelled = false } - lock.unlock() - if cancelled { - throw CancellationError() - } - - var row: [PluginCellValue] = [] - for i in 1...numCols { - let len = dbdatlen(proc, Int32(i)) - let colType = dbcoltype(proc, Int32(i)) - if len <= 0 && colType != Int32(SYBBIT) { - row.append(.null) - } else if let ptr = dbdata(proc, Int32(i)) { - if Self.isBinaryType(colType) { - row.append(.bytes(Data(bytes: ptr, count: Int(len)))) - } else { - let str = Self.columnValueAsString(proc: proc, ptr: ptr, srcType: colType, srcLen: len) - row.append(PluginCellValue.fromOptional(str)) - } - } else { - row.append(.null) - } - } - allRows.append(row) - if allRows.count >= PluginRowLimits.emergencyMax { - truncated = true - break - } - } - } - - let affectedRows = allColumns.isEmpty ? 0 : allRows.count - return FreeTDSQueryResult( - columns: allColumns, - columnTypeNames: allTypeNames, - rows: allRows, - affectedRows: affectedRows, - isTruncated: truncated - ) - } - - func streamQuery( - _ query: String, - continuation: AsyncThrowingStream.Continuation - ) async throws { - let queryToRun = String(query) - try await pluginDispatchAsync(on: queue) { [self] in - try self.streamQuerySync(queryToRun, continuation: continuation) - } - } - - private func streamQuerySync( - _ query: String, - continuation: AsyncThrowingStream.Continuation - ) throws { - guard let proc = dbproc else { - throw MSSQLPluginError.notConnected - } - - _ = dbcanquery(proc) - - lock.lock() - _isCancelled = false - lock.unlock() - - freetdsClearError(for: proc) - if dbcmd(proc, query) == FAIL { - throw MSSQLPluginError.queryFailed("Failed to prepare query") - } - if dbsqlexec(proc) == FAIL { - let detail = freetdsGetError(for: proc) - let msg = detail.isEmpty ? "Query execution failed" : detail - throw MSSQLPluginError.queryFailed(msg) - } - - var headerSent = false - - while true { - lock.lock() - let cancelledBetweenResults = _isCancelled || Task.isCancelled - if cancelledBetweenResults { _isCancelled = false } - lock.unlock() - if cancelledBetweenResults { - continuation.finish(throwing: CancellationError()) - return - } - - let resCode = dbresults(proc) - if resCode == FAIL { - continuation.finish(throwing: MSSQLPluginError.queryFailed("Query execution failed")) - return - } - if resCode == Int32(NO_MORE_RESULTS) { - break - } - - let numCols = dbnumcols(proc) - if numCols <= 0 { continue } - - if !headerSent { - var cols: [String] = [] - var typeNames: [String] = [] - for i in 1...numCols { - let name = dbcolname(proc, Int32(i)).map { String(cString: $0) } ?? "col\(i)" - cols.append(name) - typeNames.append(Self.freetdsTypeName(dbcoltype(proc, Int32(i)))) - } - continuation.yield(.header(PluginStreamHeader( - columns: cols, - columnTypeNames: typeNames, - estimatedRowCount: nil - ))) - headerSent = true - } - - let batchSize = 5_000 - var batch: [PluginRow] = [] - batch.reserveCapacity(batchSize) - - while true { - let rowCode = dbnextrow(proc) - if rowCode == Int32(NO_MORE_ROWS) { break } - if rowCode == FAIL { break } - - lock.lock() - let cancelled = _isCancelled || Task.isCancelled - if cancelled { _isCancelled = false } - lock.unlock() - if cancelled { - if !batch.isEmpty { - continuation.yield(.rows(batch)) - } - continuation.finish(throwing: CancellationError()) - return - } - - var row: [PluginCellValue] = [] - for i in 1...numCols { - let len = dbdatlen(proc, Int32(i)) - let colType = dbcoltype(proc, Int32(i)) - if len <= 0 && colType != Int32(SYBBIT) { - row.append(.null) - } else if let ptr = dbdata(proc, Int32(i)) { - if Self.isBinaryType(colType) { - row.append(.bytes(Data(bytes: ptr, count: Int(len)))) - } else { - let str = Self.columnValueAsString(proc: proc, ptr: ptr, srcType: colType, srcLen: len) - row.append(PluginCellValue.fromOptional(str)) - } - } else { - row.append(.null) - } - } - batch.append(row) - if batch.count >= batchSize { - continuation.yield(.rows(batch)) - batch.removeAll(keepingCapacity: true) - } - } - - if !batch.isEmpty { - continuation.yield(.rows(batch)) - } - } - - continuation.finish() - } - - private static func isBinaryType(_ srcType: Int32) -> Bool { - switch srcType { - case Int32(SYBBINARY), Int32(SYBVARBINARY), Int32(SYBIMAGE): - return true - default: - return false - } - } - - private static func columnValueAsString(proc: UnsafeMutablePointer, ptr: UnsafePointer, srcType: Int32, srcLen: DBINT) -> String? { - switch srcType { - case Int32(SYBCHAR), Int32(SYBVARCHAR), Int32(SYBTEXT): - return String(bytes: UnsafeBufferPointer(start: ptr, count: Int(srcLen)), encoding: .utf8) - ?? String(bytes: UnsafeBufferPointer(start: ptr, count: Int(srcLen)), encoding: .isoLatin1) - case Int32(SYBNCHAR), Int32(SYBNVARCHAR), Int32(SYBNTEXT): - // With client charset UTF-8, FreeTDS converts UTF-16 wire data to UTF-8 - // but may still report the original nvarchar type token - return String(bytes: UnsafeBufferPointer(start: ptr, count: Int(srcLen)), encoding: .utf8) - ?? String(data: Data(bytes: ptr, count: Int(srcLen)), encoding: .utf16LittleEndian) - default: - let bufSize: DBINT = 256 - var buf = [BYTE](repeating: 0, count: Int(bufSize)) - let converted = buf.withUnsafeMutableBufferPointer { bufPtr in - dbconvert(proc, srcType, ptr, srcLen, Int32(SYBCHAR), bufPtr.baseAddress, bufSize) - } - guard converted > 0, - let raw = String(bytes: buf.prefix(Int(converted)), encoding: .utf8) - else { return nil } - return MSSQLDatetimeFormatter.reformat(raw, srcType: srcType) ?? raw - } - } - - private static func freetdsTypeName(_ type: Int32) -> String { - switch type { - case Int32(SYBCHAR), Int32(SYBVARCHAR): return "varchar" - case Int32(SYBNCHAR), Int32(SYBNVARCHAR): return "nvarchar" - case Int32(SYBTEXT): return "text" - case Int32(SYBNTEXT): return "ntext" - case Int32(SYBINT1): return "tinyint" - case Int32(SYBINT2): return "smallint" - case Int32(SYBINT4): return "int" - case Int32(SYBINT8): return "bigint" - case Int32(SYBFLT8): return "float" - case Int32(SYBREAL): return "real" - case Int32(SYBDECIMAL), Int32(SYBNUMERIC): return "decimal" - case Int32(SYBMONEY), Int32(SYBMONEY4): return "money" - case Int32(SYBBIT): return "bit" - case Int32(SYBBINARY), Int32(SYBVARBINARY): return "varbinary" - case Int32(SYBIMAGE): return "image" - case Int32(SYBDATETIME), Int32(SYBDATETIMN): return "datetime" - case Int32(SYBDATETIME4): return "smalldatetime" - case Int32(SYBUNIQUE): return "uniqueidentifier" - default: return "unknown" - } - } -} - -// MARK: - Datetime Reformatting - -/// Reformats FreeTDS msdblib datetime output into ISO 8601 so values round-trip -/// through SQL Server's implicit string-to-datetime conversion. -/// -/// FreeTDS dbconvert(... SYBCHAR) emits legacy datetime values as -/// "MMM d yyyy h:mm[:ss[:fffffff]]AM/PM" (msdblib mode). SQL Server's parser -/// rejects that format on subsequent UPDATE/WHERE binding. ISO 8601 -/// (yyyy-MM-dd HH:mm:ss[.fffffff]) parses everywhere and preserves the original -/// fractional digits exactly without Foundation.Date precision loss. -internal enum MSSQLDatetimeFormatter { - /// Reformats a FreeTDS-emitted column value when the source type is one of - /// SQL Server's datetime variants. Returns nil for non-datetime types so the - /// caller falls back to the raw FreeTDS string. - static func reformat(_ raw: String, srcType: Int32) -> String? { - switch srcType { - case Int32(SYBDATETIME), Int32(SYBDATETIME4), Int32(SYBDATETIMN): - break - case 40, 41, 42: - // SYBMSDATE (40), SYBMSTIME (41), SYBMSDATETIME2 (42) from TDS 7.3+. - // Constants are not declared in the CFreeTDS stub header; matched - // by raw value. SYBMSDATETIMEOFFSET (43) is intentionally excluded - // because the offset suffix format is not verified. - break - default: - return nil - } - return parse(raw) - } - - /// Returns ISO 8601 if the input is recognized, nil otherwise. Already-ISO - /// inputs pass through verbatim. Public so tests can exercise it directly. - static func parse(_ raw: String) -> String? { - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return nil } - if isAlreadyISO(trimmed) { - return trimmed - } - return parseLegacyAMPM(trimmed) - } - - /// FreeTDS emits "yyyy-MM-dd ..." for some TDS 7.3+ types. Detect the prefix - /// and pass through, since the rest of the value is already SQL Server parseable. - static func isAlreadyISO(_ s: String) -> Bool { - let chars = Array(s) - guard chars.count >= 10 else { return false } - return chars[0].isASCIIDigit && chars[1].isASCIIDigit - && chars[2].isASCIIDigit && chars[3].isASCIIDigit - && chars[4] == "-" - && chars[5].isASCIIDigit && chars[6].isASCIIDigit - && chars[7] == "-" - && chars[8].isASCIIDigit && chars[9].isASCIIDigit - } - - /// Parses "MMM d yyyy h:mm[:ss[:fff[fffff]]] AM|PM" (msdblib 12-hour) or the - /// 24-hour variant without an AM/PM marker. Returns ISO 8601 with fractional - /// digits preserved verbatim. - private static func parseLegacyAMPM(_ raw: String) -> String? { - let scanner = Scanner(string: raw) - scanner.charactersToBeSkipped = nil - _ = scanner.scanCharacters(from: .whitespaces) - - guard let monthToken = scanner.scanCharacters(from: .letters), - monthToken.count >= 3, - let month = monthNamesByPrefix[String(monthToken.prefix(3))] - else { return nil } - - _ = scanner.scanCharacters(from: .whitespaces) - guard let day = scanner.scanInt(), (1...31).contains(day) else { return nil } - _ = scanner.scanCharacters(from: .whitespaces) - guard let year = scanner.scanInt(), (1...9999).contains(year) else { return nil } - _ = scanner.scanCharacters(from: .whitespaces) - guard var hour = scanner.scanInt() else { return nil } - - var minute = 0 - var second = 0 - var fractional = "" - - if scanner.scanString(":") != nil { - guard let m = scanner.scanInt(), (0...59).contains(m) else { return nil } - minute = m - } - if scanner.scanString(":") != nil { - guard let s = scanner.scanInt(), (0...59).contains(s) else { return nil } - second = s - } - if scanner.scanString(":") != nil || scanner.scanString(".") != nil { - fractional = scanner.scanCharacters(from: .decimalDigits) ?? "" - } - - _ = scanner.scanCharacters(from: .whitespaces) - let ampm = scanner.scanCharacters(from: .letters)?.uppercased() - - if let ampm { - guard ampm == "AM" || ampm == "PM" else { return nil } - guard (1...12).contains(hour) else { return nil } - if ampm == "PM", hour < 12 { - hour += 12 - } else if ampm == "AM", hour == 12 { - hour = 0 - } - } else { - guard (0...23).contains(hour) else { return nil } - } - - var iso = String(format: "%04d-%02d-%02d %02d:%02d:%02d", year, month, day, hour, minute, second) - if !fractional.isEmpty { - iso += "." + fractional - } - return iso - } - - private static let monthNamesByPrefix: [String: Int] = [ - "Jan": 1, "Feb": 2, "Mar": 3, "Apr": 4, "May": 5, "Jun": 6, - "Jul": 7, "Aug": 8, "Sep": 9, "Oct": 10, "Nov": 11, "Dec": 12 - ] -} - -private extension Character { - var isASCIIDigit: Bool { isASCII && isNumber } -} - // MARK: - MSSQL Plugin Driver final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { @@ -794,18 +216,24 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Connection func connect() async throws { - let conn = FreeTDSConnection( + let options = MSSQLConnectionOptions( host: config.host, port: config.port, user: config.username, password: config.password, database: config.database, - ssl: config.ssl + schema: _currentSchema, + encryptionFlag: MSSQLSSLMapping.freetdsEncryptionFlag(for: config.ssl.mode) ) - try await conn.connect() + let conn = FreeTDSConnection(options: options) + do { + try await conn.connect() + } catch let error as MSSQLCoreError { + throw MSSQLPluginError(coreError: error) + } self.freeTDSConn = conn - if let result = try? await conn.executeQuery("SELECT SCHEMA_NAME()"), + if let result = try? await executeInternal("SELECT SCHEMA_NAME()"), let serverSchema = result.rows.first?.first?.asText, !serverSchema.isEmpty { _currentSchema = serverSchema @@ -818,12 +246,25 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _currentSchema = formSchema } - if let result = try? await conn.executeQuery("SELECT @@VERSION"), + if let result = try? await executeInternal("SELECT @@VERSION"), let versionStr = result.rows.first?.first?.asText { _serverVersion = String(versionStr.prefix(50)) } } + private func executeInternal(_ query: String) async throws -> PluginQueryResult { + guard let conn = freeTDSConn else { + throw MSSQLPluginError.notConnected + } + let startTime = Date() + do { + let raw = try await conn.executeQuery(query) + return raw.toPluginResult(executionTime: Date().timeIntervalSince(startTime)) + } catch let error as MSSQLCoreError { + throw MSSQLPluginError(coreError: error) + } + } + func disconnect() { freeTDSConn?.disconnect() freeTDSConn = nil @@ -842,19 +283,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Query Execution func execute(query: String) async throws -> PluginQueryResult { - guard let conn = freeTDSConn else { - throw MSSQLPluginError.notConnected - } - let startTime = Date() - let result = try await conn.executeQuery(query) - return PluginQueryResult( - columns: result.columns, - columnTypeNames: result.columnTypeNames, - rows: result.rows, - rowsAffected: result.affectedRows, - executionTime: Date().timeIntervalSince(startTime), - isTruncated: result.isTruncated - ) + try await executeInternal(query) } // MARK: - DML Statement Generation @@ -1025,8 +454,36 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } return AsyncThrowingStream(bufferingPolicy: .unbounded) { continuation in let streamTask = Task { + let coreStream = AsyncThrowingStream { coreContinuation in + Task { + do { + try await conn.streamQuery(query, continuation: coreContinuation) + } catch let error as MSSQLCoreError { + coreContinuation.finish(throwing: MSSQLPluginError(coreError: error)) + } catch { + coreContinuation.finish(throwing: error) + } + } + } do { - try await conn.streamQuery(query, continuation: continuation) + for try await element in coreStream { + switch element { + case .header(let columns): + continuation.yield(.header(PluginStreamHeader( + columns: columns.map { $0.name }, + columnTypeNames: columns.map { $0.type.canonicalName }, + estimatedRowCount: nil + ))) + case .rows(let batch): + let pluginRows: [PluginRow] = batch.map { row in + row.map { $0.asPluginCell } + } + continuation.yield(.rows(pluginRows)) + case .affectedRows: + break + } + } + continuation.finish() } catch { continuation.finish(throwing: error) } diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 2afea6dd3..1d578deec 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 5AD1D8C12FB5000000000001 /* TableProMSSQLCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */; }; 5A32BBFB2F9D5EAB00BAEB5F /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = 5A32BBFA2F9D5EAB00BAEB5F /* X509 */; }; 5A32BC0B2F9D659100BAEB5F /* tablepro-mcp in Copy Files */ = {isa = PBXBuildFile; fileRef = 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 5A3A69B82F976F38000AC5B2 /* GhosttyTerminal in Frameworks */ = {isa = PBXBuildFile; productRef = 5A3A69B72F976F38000AC5B2 /* GhosttyTerminal */; }; @@ -727,6 +728,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5AD1D8C12FB5000000000001 /* TableProMSSQLCore in Frameworks */, 5A864000A00000000 /* TableProPluginKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1200,6 +1202,9 @@ 5A864000500000000 /* Plugins/MSSQLDriverPlugin */, ); name = MSSQLDriver; + packageProductDependencies = ( + 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */, + ); productName = MSSQLDriver; productReference = 5A864000100000000 /* MSSQLDriver.tableplugin */; productType = "com.apple.product-type.bundle"; @@ -4110,6 +4115,11 @@ isa = XCSwiftPackageProductDependency; productName = TableProAnalytics; }; + 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */ = { + isa = XCSwiftPackageProductDependency; + package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; + productName = TableProMSSQLCore; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 5A1091BF2EF17EDC0055EA7C /* Project object */; diff --git a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj index f9746ad8e..d55891cb8 100644 --- a/TableProMobile/TableProMobile.xcodeproj/project.pbxproj +++ b/TableProMobile/TableProMobile.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 5AD1F2002FB5500000000002 /* FreeTDSConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AD1F2002FB5500000000001 /* FreeTDSConnection.swift */; }; 5A72D6232F97A69500E2ADE0 /* Secrets.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 5A72D6222F97A69500E2ADE0 /* Secrets.xcconfig */; }; 5A7E81B12F95F23600EEF236 /* TableProAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 5A87EEED2F7F893000D028D1 /* TableProAnalytics */; }; 5A87EEED2F7F893000D028D0 /* TableProSync in Frameworks */ = {isa = PBXBuildFile; productRef = 5A87EEEC2F7F893000D028D0 /* TableProSync */; }; @@ -60,6 +61,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 5AD1F2002FB5500000000001 /* FreeTDSConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FreeTDSConnection.swift; path = ../Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift; sourceTree = ""; }; 5A72D6222F97A69500E2ADE0 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; 5A87ECDD2F7F88F200D028D0 /* AIPromptTemplates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AIPromptTemplates.swift; sourceTree = ""; }; 5A87ECDE2F7F88F200D028D0 /* AIPromptTemplates+InlineSuggest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AIPromptTemplates+InlineSuggest.swift"; sourceTree = ""; }; @@ -1834,6 +1836,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 5AD1F2002FB5500000000002 /* FreeTDSConnection.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift index 8a580eea9..fcfdd23b7 100644 --- a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -30,6 +30,7 @@ final class ConnectionFormViewModel { var password = "" var database = "" var sslEnabled = false + var mssqlSSLMode: SSLConfiguration.SSLMode = .disable // Organization var groupId: UUID? @@ -72,6 +73,7 @@ final class ConnectionFormViewModel { username = conn.username database = conn.database sslEnabled = conn.sslEnabled + mssqlSSLMode = conn.sslConfiguration?.mode ?? .disable sshEnabled = conn.sshEnabled groupId = conn.groupId tagId = conn.tagId @@ -318,10 +320,13 @@ final class ConnectionFormViewModel { username: username, database: database, sshEnabled: sshEnabled, - sslEnabled: sslEnabled, + sslEnabled: type == .mssql ? (mssqlSSLMode != .disable) : sslEnabled, groupId: groupId, tagId: tagId ) + if type == .mssql { + conn.sslConfiguration = SSLConfiguration(mode: mssqlSSLMode) + } conn.safeModeLevel = safeModeLevel if sshEnabled { conn.sshConfiguration = SSHConfiguration( diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 9cb64455b..1712e14b9 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -56,7 +56,16 @@ struct ConnectionFormView: View { if viewModel.type != .sqlite { Section { - Toggle("SSL", isOn: $viewModel.sslEnabled) + if viewModel.type == .mssql { + Picker(String(localized: "SSL Mode"), selection: $viewModel.mssqlSSLMode) { + Text(String(localized: "Disabled")).tag(SSLConfiguration.SSLMode.disable) + Text(String(localized: "Required")).tag(SSLConfiguration.SSLMode.require) + Text(String(localized: "Verify CA")).tag(SSLConfiguration.SSLMode.verifyCa) + Text(String(localized: "Verify Full")).tag(SSLConfiguration.SSLMode.verifyFull) + } + } else { + Toggle("SSL", isOn: $viewModel.sslEnabled) + } } sshSection(viewModel: viewModel) } diff --git a/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift b/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift index 7b38d6e4b..4619b6a2a 100644 --- a/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift +++ b/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift @@ -192,4 +192,56 @@ final class MSSQLDriverTests: XCTestCase { XCTAssertFalse(desc.isEmpty) } } + + func testUnreachableHostFails() async throws { + try XCTSkipIf(Self.loadTestConfig() == nil, "no test config") + let connection = DatabaseConnection( + name: "unreachable", + type: .mssql, + host: "192.0.2.1", + port: 1433, + username: "sa", + database: "master", + additionalFields: ["mssqlSchema": "dbo"] + ) + let badDriver = MSSQLDriver(connection: connection, password: "x") + do { + try await badDriver.connect() + XCTFail("Connecting to RFC5737 TEST-NET should fail") + } catch { + let desc = (error as? LocalizedError)?.errorDescription ?? "\(error)" + XCTAssertFalse(desc.isEmpty) + } + } + + // Note: FreeTDS db-lib does not expose per-connection cert validation. `verifyFull` maps to + // `require` (TLS on, but no CA chain check). Testing strict TLS rejection requires either + // building FreeTDS with custom OpenSSL callbacks or trusting the certificate via machine-wide + // freetds.conf, neither of which is per-connection. Out of scope for this driver. + + func testMultiColumnForeignKey() async throws { + let driver = try XCTUnwrap(driver) + _ = try await driver.execute(query: """ + IF OBJECT_ID('dbo.tp_fk_child', 'U') IS NOT NULL DROP TABLE dbo.tp_fk_child; + IF OBJECT_ID('dbo.tp_fk_parent', 'U') IS NOT NULL DROP TABLE dbo.tp_fk_parent; + CREATE TABLE dbo.tp_fk_parent ( + tenant_id INT NOT NULL, + external_id INT NOT NULL, + CONSTRAINT pk_tp_fk_parent PRIMARY KEY (tenant_id, external_id) + ); + CREATE TABLE dbo.tp_fk_child ( + id INT PRIMARY KEY, + tenant_id INT NOT NULL, + external_id INT NOT NULL, + CONSTRAINT fk_tp_fk_child_parent FOREIGN KEY (tenant_id, external_id) + REFERENCES dbo.tp_fk_parent (tenant_id, external_id) + ); + """) + let fks = try await driver.fetchForeignKeys(table: "tp_fk_child", schema: "dbo") + let matched = fks.filter { $0.name == "fk_tp_fk_child_parent" } + XCTAssertEqual(matched.count, 2, "composite FK should produce two rows") + let cols = Set(matched.map { $0.column }) + XCTAssertEqual(cols, Set(["tenant_id", "external_id"])) + XCTAssertTrue(matched.allSatisfy { $0.referencedTable == "tp_fk_parent" }) + } } From 1a772b9e2e24bb003ab5ecf82420cad9d83075de Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 13 May 2026 19:07:36 +0700 Subject: [PATCH 4/5] fix(mssql): SSL UX, login timeout knob, restore daily-repo-status workflow --- .github/workflows/daily-repo-status.lock.yml | 1130 +++++++++++++++++ .github/workflows/daily-repo-status.md | 58 + .../MSSQLConnectionOptions.swift | 6 +- .../CFreeTDS/include/sybdb.h | 3 + .../MSSQLDriverPlugin/FreeTDSConnection.swift | 16 + .../TableProMobile/Drivers/MSSQLDriver.swift | 3 +- .../ViewModels/ConnectionFormViewModel.swift | 5 +- .../Views/ConnectionFormView.swift | 5 +- .../Drivers/MSSQLDriverTests.swift | 32 +- 9 files changed, 1252 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/daily-repo-status.lock.yml create mode 100644 .github/workflows/daily-repo-status.md diff --git a/.github/workflows/daily-repo-status.lock.yml b/.github/workflows/daily-repo-status.lock.yml new file mode 100644 index 000000000..25e980a29 --- /dev/null +++ b/.github/workflows/daily-repo-status.lock.yml @@ -0,0 +1,1130 @@ +# +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw (v0.57.2). DO NOT EDIT. +# +# To update this file, edit githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# This workflow creates daily repo status reports. It gathers recent repository +# activity (issues, PRs, discussions, releases, code changes) and generates +# engaging GitHub issues with productivity insights, community highlights, +# and project recommendations. +# +# Source: githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f +# +# gh-aw-metadata: {"schema_version":"v2","frontmatter_hash":"1937f4c9ad5978528ec699e525271fa402d8d659376eb7287f1ebec69c681d2c","compiler_version":"v0.57.2","strict":true} + +name: "Daily Repo Status" +"on": + schedule: + - cron: "23 19 * * *" + # Friendly format: daily (scattered) + workflow_dispatch: + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}" + +run-name: "Daily Repo Status" + +jobs: + activation: + runs-on: ubuntu-slim + permissions: + contents: read + outputs: + comment_id: "" + comment_repo: "" + model: ${{ steps.generate_aw_info.outputs.model }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 + with: + destination: /opt/gh-aw/actions + - name: Generate agentic run info + id: generate_aw_info + env: + GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} + GH_AW_INFO_VERSION: "" + GH_AW_INFO_AGENT_VERSION: "latest" + GH_AW_INFO_CLI_VERSION: "v0.57.2" + GH_AW_INFO_WORKFLOW_NAME: "Daily Repo Status" + GH_AW_INFO_EXPERIMENTAL: "false" + GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" + GH_AW_INFO_STAGED: "false" + GH_AW_INFO_ALLOWED_DOMAINS: '["defaults"]' + GH_AW_INFO_FIREWALL_ENABLED: "true" + GH_AW_INFO_AWF_VERSION: "v0.23.0" + GH_AW_INFO_AWMG_VERSION: "" + GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_COMPILED_STRICT: "true" + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { main } = require('/opt/gh-aw/actions/generate_aw_info.cjs'); + await main(core, context); + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: /opt/gh-aw/actions/validate_multi_secret.sh COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: | + .github + .agents + sparse-checkout-cone-mode: true + fetch-depth: 1 + - name: Check workflow file timestamps + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_WORKFLOW_FILE: "daily-repo-status.lock.yml" + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + run: | + bash /opt/gh-aw/actions/create_prompt_first.sh + { + cat << 'GH_AW_PROMPT_EOF' + + GH_AW_PROMPT_EOF + cat "/opt/gh-aw/prompts/xpia.md" + cat "/opt/gh-aw/prompts/temp_folder_prompt.md" + cat "/opt/gh-aw/prompts/markdown.md" + cat "/opt/gh-aw/prompts/safe_outputs_prompt.md" + cat << 'GH_AW_PROMPT_EOF' + + Tools: create_issue, missing_tool, missing_data, noop + + + The following GitHub context information is available for this workflow: + {{#if __GH_AW_GITHUB_ACTOR__ }} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if __GH_AW_GITHUB_REPOSITORY__ }} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if __GH_AW_GITHUB_WORKSPACE__ }} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ }} + - **issue-number**: #__GH_AW_GITHUB_EVENT_ISSUE_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ }} + - **discussion-number**: #__GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ }} + - **pull-request-number**: #__GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER__ + {{/if}} + {{#if __GH_AW_GITHUB_EVENT_COMMENT_ID__ }} + - **comment-id**: __GH_AW_GITHUB_EVENT_COMMENT_ID__ + {{/if}} + {{#if __GH_AW_GITHUB_RUN_ID__ }} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' + + GH_AW_PROMPT_EOF + cat << 'GH_AW_PROMPT_EOF' + {{#runtime-import .github/workflows/daily-repo-status.md}} + GH_AW_PROMPT_EOF + } > "$GH_AW_PROMPT" + - name: Interpolate variables and render templates + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }} + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: ${{ github.event.discussion.number }} + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }} + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + + const substitutePlaceholders = require('/opt/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_EVENT_COMMENT_ID: process.env.GH_AW_GITHUB_EVENT_COMMENT_ID, + GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER: process.env.GH_AW_GITHUB_EVENT_DISCUSSION_NUMBER, + GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER, + GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER: process.env.GH_AW_GITHUB_EVENT_PULL_REQUEST_NUMBER, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/validate_prompt_placeholders.sh + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + run: bash /opt/gh-aw/actions/print_prompt_summary.sh + - name: Upload activation artifact + if: success() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: activation + path: | + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/aw-prompts/prompt.txt + retention-days: 1 + + agent: + needs: activation + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_SAFE_OUTPUTS: /opt/gh-aw/safeoutputs/outputs.jsonl + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + GH_AW_WORKFLOW_ID_SANITIZED: dailyrepostatus + outputs: + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }} + detection_success: ${{ steps.detection_conclusion.outputs.success }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + inference_access_error: ${{ steps.detect-inference-error.outputs.inference_access_error || 'false' }} + model: ${{ needs.activation.outputs.model }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 + with: + destination: /opt/gh-aw/actions + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash /opt/gh-aw/actions/create_gh_aw_tmp_dir.sh + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + (github.event.pull_request) || (github.event.issue.pull_request) + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Install GitHub Copilot CLI + run: /opt/gh-aw/actions/install_copilot_cli.sh latest + - name: Install awf binary + run: bash /opt/gh-aw/actions/install_awf_binary.sh v0.23.0 + - name: Download container images + run: bash /opt/gh-aw/actions/download_docker_images.sh ghcr.io/github/gh-aw-firewall/agent:0.23.0 ghcr.io/github/gh-aw-firewall/api-proxy:0.23.0 ghcr.io/github/gh-aw-firewall/squid:0.23.0 ghcr.io/github/gh-aw-mcpg:v0.1.8 ghcr.io/github/github-mcp-server:v0.32.0 node:lts-alpine + - name: Write Safe Outputs Config + run: | + mkdir -p /opt/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/safeoutputs + mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs + cat > /opt/gh-aw/safeoutputs/config.json << 'GH_AW_SAFE_OUTPUTS_CONFIG_EOF' + {"create_issue":{"max":1},"mentions":{"enabled":false},"missing_data":{},"missing_tool":{},"noop":{"max":1}} + GH_AW_SAFE_OUTPUTS_CONFIG_EOF + cat > /opt/gh-aw/safeoutputs/tools.json << 'GH_AW_SAFE_OUTPUTS_TOOLS_EOF' + [ + { + "description": "Create a new GitHub issue for tracking bugs, feature requests, or tasks. Use this for actionable work items that need assignment, labeling, and status tracking. For reports, announcements, or status updates that don't require task tracking, use create_discussion instead. CONSTRAINTS: Maximum 1 issue(s) can be created. Title will be prefixed with \"[repo-status] \". Labels [\"report\" \"daily-status\"] will be automatically added.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "body": { + "description": "Detailed issue description in Markdown. Do NOT repeat the title as a heading since it already appears as the issue's h1. Include context, reproduction steps, or acceptance criteria as appropriate.", + "type": "string" + }, + "integrity": { + "description": "Trustworthiness level of the message source (e.g., \"low\", \"medium\", \"high\").", + "type": "string" + }, + "labels": { + "description": "Labels to categorize the issue (e.g., 'bug', 'enhancement'). Labels must exist in the repository.", + "items": { + "type": "string" + }, + "type": "array" + }, + "parent": { + "description": "Parent issue number for creating sub-issues. This is the numeric ID from the GitHub URL (e.g., 42 in github.com/owner/repo/issues/42). Can also be a temporary_id (e.g., 'aw_abc123', 'aw_Test123') from a previously created issue in the same workflow run.", + "type": [ + "number", + "string" + ] + }, + "secrecy": { + "description": "Confidentiality level of the message content (e.g., \"public\", \"internal\", \"private\").", + "type": "string" + }, + "temporary_id": { + "description": "Unique temporary identifier for referencing this issue before it's created. Format: 'aw_' followed by 3 to 12 alphanumeric characters (e.g., 'aw_abc1', 'aw_Test123'). Use '#aw_ID' in body text to reference other issues by their temporary_id; these are replaced with actual issue numbers after creation.", + "pattern": "^aw_[A-Za-z0-9]{3,12}$", + "type": "string" + }, + "title": { + "description": "Concise issue title summarizing the bug, feature, or task. The title appears as the main heading, so keep it brief and descriptive.", + "type": "string" + } + }, + "required": [ + "title", + "body" + ], + "type": "object" + }, + "name": "create_issue" + }, + { + "description": "Report that a tool or capability needed to complete the task is not available, or share any information you deem important about missing functionality or limitations. Use this when you cannot accomplish what was requested because the required functionality is missing or access is restricted.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "alternatives": { + "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", + "type": "string" + }, + "integrity": { + "description": "Trustworthiness level of the message source (e.g., \"low\", \"medium\", \"high\").", + "type": "string" + }, + "reason": { + "description": "Explanation of why this tool is needed or what information you want to share about the limitation (max 256 characters).", + "type": "string" + }, + "secrecy": { + "description": "Confidentiality level of the message content (e.g., \"public\", \"internal\", \"private\").", + "type": "string" + }, + "tool": { + "description": "Optional: Name or description of the missing tool or capability (max 128 characters). Be specific about what functionality is needed.", + "type": "string" + } + }, + "required": [ + "reason" + ], + "type": "object" + }, + "name": "missing_tool" + }, + { + "description": "Log a transparency message when no significant actions are needed. Use this to confirm workflow completion and provide visibility when analysis is complete but no changes or outputs are required (e.g., 'No issues found', 'All checks passed'). This ensures the workflow produces human-visible output even when no other actions are taken.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "integrity": { + "description": "Trustworthiness level of the message source (e.g., \"low\", \"medium\", \"high\").", + "type": "string" + }, + "message": { + "description": "Status or completion message to log. Should explain what was analyzed and the outcome (e.g., 'Code review complete - no issues found', 'Analysis complete - all tests passing').", + "type": "string" + }, + "secrecy": { + "description": "Confidentiality level of the message content (e.g., \"public\", \"internal\", \"private\").", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "name": "noop" + }, + { + "description": "Report that data or information needed to complete the task is not available. Use this when you cannot accomplish what was requested because required data, context, or information is missing.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "alternatives": { + "description": "Any workarounds, manual steps, or alternative approaches the user could take (max 256 characters).", + "type": "string" + }, + "context": { + "description": "Additional context about the missing data or where it should come from (max 256 characters).", + "type": "string" + }, + "data_type": { + "description": "Type or description of the missing data or information (max 128 characters). Be specific about what data is needed.", + "type": "string" + }, + "integrity": { + "description": "Trustworthiness level of the message source (e.g., \"low\", \"medium\", \"high\").", + "type": "string" + }, + "reason": { + "description": "Explanation of why this data is needed to complete the task (max 256 characters).", + "type": "string" + }, + "secrecy": { + "description": "Confidentiality level of the message content (e.g., \"public\", \"internal\", \"private\").", + "type": "string" + } + }, + "required": [], + "type": "object" + }, + "name": "missing_data" + } + ] + GH_AW_SAFE_OUTPUTS_TOOLS_EOF + cat > /opt/gh-aw/safeoutputs/validation.json << 'GH_AW_SAFE_OUTPUTS_VALIDATION_EOF' + { + "create_issue": { + "defaultMax": 1, + "fields": { + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "parent": { + "issueOrPRNumber": true + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "temporary_id": { + "type": "string" + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + } + } + GH_AW_SAFE_OUTPUTS_VALIDATION_EOF + - name: Generate Safe Outputs MCP Server Config + id: safe-outputs-config + run: | + # Generate a secure random API key (360 bits of entropy, 40+ chars) + # Mask immediately to prevent timing vulnerabilities + API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${API_KEY}" + + PORT=3001 + + # Set outputs for next steps + { + echo "safe_outputs_api_key=${API_KEY}" + echo "safe_outputs_port=${PORT}" + } >> "$GITHUB_OUTPUT" + + echo "Safe Outputs MCP server will run on port ${PORT}" + + - name: Start Safe Outputs MCP HTTP Server + id: safe-outputs-start + env: + DEBUG: '*' + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: /opt/gh-aw/safeoutputs/tools.json + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: /opt/gh-aw/safeoutputs/config.json + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + run: | + # Environment variables are set above to prevent template injection + export DEBUG + export GH_AW_SAFE_OUTPUTS_PORT + export GH_AW_SAFE_OUTPUTS_API_KEY + export GH_AW_SAFE_OUTPUTS_TOOLS_PATH + export GH_AW_SAFE_OUTPUTS_CONFIG_PATH + export GH_AW_MCP_LOG_DIR + + bash /opt/gh-aw/actions/start_safe_outputs_server.sh + + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + set -eo pipefail + mkdir -p /tmp/gh-aw/mcp-config + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="80" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host -v /var/run/docker.sock:/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_LOCKDOWN -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.1.8' + + mkdir -p /home/runner/.copilot + cat << GH_AW_MCP_CONFIG_EOF | bash /opt/gh-aw/actions/start_mcp_gateway.sh + { + "mcpServers": { + "github": { + "type": "stdio", + "container": "ghcr.io/github/github-mcp-server:v0.32.0", + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}", + "GITHUB_READ_ONLY": "1", + "GITHUB_TOOLSETS": "context,repos,issues,pull_requests" + } + }, + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", + "headers": { + "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_EOF + - name: Download activation artifact + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8 + with: + name: activation + path: /tmp/gh-aw + - name: Clean git credentials + run: bash /opt/gh-aw/actions/clean_git_credentials.sh + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + touch /tmp/gh-aw/agent-step-summary.md + # shellcheck disable=SC1003 + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \ + -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-all-tools --allow-all-paths --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || '' }} + GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json + GH_AW_PHASE: agent + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_VERSION: v0.57.2 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + XDG_CONFIG_HOME: /home/runner + - name: Detect inference access error + id: detect-inference-error + if: always() + continue-on-error: true + run: bash /opt/gh-aw/actions/detect_inference_access_error.sh + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${{ github.token }}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: | + # Copy Copilot session state files to logs folder for artifact collection + # This ensures they are in /tmp/gh-aw/ where secret redaction can scan them + SESSION_STATE_DIR="$HOME/.copilot/session-state" + LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" + + if [ -d "$SESSION_STATE_DIR" ]; then + echo "Copying Copilot session state files from $SESSION_STATE_DIR to $LOGS_DIR" + mkdir -p "$LOGS_DIR" + cp -v "$SESSION_STATE_DIR"/*.jsonl "$LOGS_DIR/" 2>/dev/null || true + echo "Session state files copied successfully" + else + echo "No session-state directory found at $SESSION_STATE_DIR" + fi + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash /opt/gh-aw/actions/stop_mcp_gateway.sh "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Append agent step summary + if: always() + run: bash /opt/gh-aw/actions/append_agent_step_summary.sh + - name: Upload Safe Outputs + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: safe-output + path: ${{ env.GH_AW_SAFE_OUTPUTS }} + if-no-files-found: warn + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_SAFE_OUTPUTS: ${{ env.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" + GH_AW_ALLOWED_GITHUB_REFS: "" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Upload sanitized agent output + if: always() && env.GH_AW_AGENT_OUTPUT + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: agent-output + path: ${{ env.GH_AW_AGENT_OUTPUT }} + if-no-files-found: warn + - name: Upload engine output files + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: agent_outputs + path: | + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + if-no-files-found: ignore + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+r /tmp/gh-aw/sandbox/firewall/logs 2>/dev/null || true + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: agent-artifacts + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/agent/ + if-no-files-found: ignore + # --- Threat Detection (inline) --- + - name: Check if detection needed + id: detection_guard + if: always() + env: + OUTPUT_TYPES: ${{ steps.collect_output.outputs.output_types }} + HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + run: | + if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then + echo "run_detection=true" >> "$GITHUB_OUTPUT" + echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH" + else + echo "run_detection=false" >> "$GITHUB_OUTPUT" + echo "Detection skipped: no agent outputs or patches to analyze" + fi + - name: Clear MCP configuration for detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + rm -f /tmp/gh-aw/mcp-config/mcp-servers.json + rm -f /home/runner/.copilot/mcp-config.json + rm -f "$GITHUB_WORKSPACE/.gemini/settings.json" + - name: Prepare threat detection files + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection/aw-prompts + cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true + cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true + for f in /tmp/gh-aw/aw-*.patch; do + [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true + done + echo "Prepared threat detection files:" + ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true + - name: Setup threat detection + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + WORKFLOW_NAME: "Daily Repo Status" + WORKFLOW_DESCRIPTION: "This workflow creates daily repo status reports. It gathers recent repository\nactivity (issues, PRs, discussions, releases, code changes) and generates\nengaging GitHub issues with productivity insights, community highlights,\nand project recommendations." + HAS_PATCH: ${{ steps.collect_output.outputs.has_patch }} + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/setup_threat_detection.cjs'); + await main(); + - name: Ensure threat-detection directory and log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + run: | + mkdir -p /tmp/gh-aw/threat-detection + touch /tmp/gh-aw/threat-detection/detection.log + - name: Execute GitHub Copilot CLI + if: always() && steps.detection_guard.outputs.run_detection == 'true' + id: detection_agentic_execution + # Copilot CLI tool arguments (sorted): + # --allow-tool shell(cat) + # --allow-tool shell(grep) + # --allow-tool shell(head) + # --allow-tool shell(jq) + # --allow-tool shell(ls) + # --allow-tool shell(tail) + # --allow-tool shell(wc) + timeout-minutes: 20 + run: | + set -o pipefail + touch /tmp/gh-aw/agent-step-summary.md + # shellcheck disable=SC1003 + sudo -E awf --env-all --container-workdir "${GITHUB_WORKSPACE}" --allow-domains "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,github.com,host.docker.internal,raw.githubusercontent.com,registry.npmjs.org,telemetry.enterprise.githubcopilot.com" --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --enable-host-access --image-tag 0.23.0 --skip-pull --enable-api-proxy \ + -- /bin/bash -c '/usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --add-dir "${GITHUB_WORKSPACE}" --disable-builtin-mcps --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(wc)'\'' --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + env: + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || '' }} + GH_AW_PHASE: detection + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_VERSION: v0.57.2 + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + XDG_CONFIG_HOME: /home/runner + - name: Parse threat detection results + id: parse_detection_results + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + with: + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/parse_threat_detection_results.cjs'); + await main(); + - name: Upload threat detection log + if: always() && steps.detection_guard.outputs.run_detection == 'true' + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: threat-detection.log + path: /tmp/gh-aw/threat-detection/detection.log + if-no-files-found: ignore + - name: Set detection conclusion + id: detection_conclusion + if: always() + env: + RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }} + DETECTION_SUCCESS: ${{ steps.parse_detection_results.outputs.success }} + run: | + if [[ "$RUN_DETECTION" != "true" ]]; then + echo "conclusion=skipped" >> "$GITHUB_OUTPUT" + echo "success=true" >> "$GITHUB_OUTPUT" + echo "Detection was not needed, marking as skipped" + elif [[ "$DETECTION_SUCCESS" == "true" ]]; then + echo "conclusion=success" >> "$GITHUB_OUTPUT" + echo "success=true" >> "$GITHUB_OUTPUT" + echo "Detection passed successfully" + else + echo "conclusion=failure" >> "$GITHUB_OUTPUT" + echo "success=false" >> "$GITHUB_OUTPUT" + echo "Detection found issues" + fi + + conclusion: + needs: + - activation + - agent + - safe_outputs + if: (always()) && (needs.agent.result != 'skipped') + runs-on: ubuntu-slim + permissions: + contents: read + issues: write + concurrency: + group: "gh-aw-conclusion-daily-repo-status" + cancel-in-progress: false + outputs: + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 + with: + destination: /opt/gh-aw/actions + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8 + with: + name: agent-output + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process No-Op Messages + id: noop + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "Daily Repo Status" + GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/346204513ecfa08b81566450d7d599556807389f/workflows/daily-repo-status.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/noop.cjs'); + await main(); + - name: Record Missing Tool + id: missing_tool + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Daily Repo Status" + GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/346204513ecfa08b81566450d7d599556807389f/workflows/daily-repo-status.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Handle Agent Failure + id: handle_agent_failure + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Daily Repo Status" + GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/346204513ecfa08b81566450d7d599556807389f/workflows/daily-repo-status.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "daily-repo-status" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_GROUP_REPORTS: "false" + GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_TIMEOUT_MINUTES: "20" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + - name: Handle No-Op Message + id: handle_noop_message + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Daily Repo Status" + GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/346204513ecfa08b81566450d7d599556807389f/workflows/daily-repo-status.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_MESSAGE: ${{ steps.noop.outputs.noop_message }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/handle_noop_message.cjs'); + await main(); + + safe_outputs: + needs: agent + if: ((!cancelled()) && (needs.agent.result != 'skipped')) && (needs.agent.outputs.detection_success == 'true') + runs-on: ubuntu-slim + permissions: + contents: read + issues: write + timeout-minutes: 15 + env: + GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/daily-repo-status" + GH_AW_ENGINE_ID: "copilot" + GH_AW_WORKFLOW_ID: "daily-repo-status" + GH_AW_WORKFLOW_NAME: "Daily Repo Status" + GH_AW_WORKFLOW_SOURCE: "githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/githubnext/agentics/tree/346204513ecfa08b81566450d7d599556807389f/workflows/daily-repo-status.md" + outputs: + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + created_issue_number: ${{ steps.process_safe_outputs.outputs.created_issue_number }} + created_issue_url: ${{ steps.process_safe_outputs.outputs.created_issue_url }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Setup Scripts + uses: github/gh-aw/actions/setup@32b3a711a9ee97d38e3989c90af0385aff0066a7 # v0.57.2 + with: + destination: /opt/gh-aw/actions + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8 + with: + name: agent-output + path: /tmp/gh-aw/safeoutputs/ + - name: Setup agent output environment variable + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/safeoutputs/ + find "/tmp/gh-aw/safeoutputs/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/safeoutputs/agent_output.json" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + GH_AW_AGENT_OUTPUT: ${{ env.GH_AW_AGENT_OUTPUT }} + GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_issue\":{\"close_older_issues\":true,\"labels\":[\"report\",\"daily-status\"],\"max\":1,\"title_prefix\":\"[repo-status] \"},\"missing_data\":{},\"missing_tool\":{}}" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('/opt/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('/opt/gh-aw/actions/safe_output_handler_manager.cjs'); + await main(); + - name: Upload safe output items manifest + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: safe-output-items + path: /tmp/safe-output-items.jsonl + if-no-files-found: warn + diff --git a/.github/workflows/daily-repo-status.md b/.github/workflows/daily-repo-status.md new file mode 100644 index 000000000..5ab7aafe3 --- /dev/null +++ b/.github/workflows/daily-repo-status.md @@ -0,0 +1,58 @@ +--- +description: | + This workflow creates daily repo status reports. It gathers recent repository + activity (issues, PRs, discussions, releases, code changes) and generates + engaging GitHub issues with productivity insights, community highlights, + and project recommendations. + +on: + schedule: daily + workflow_dispatch: + +permissions: + contents: read + issues: read + pull-requests: read + +network: defaults + +tools: + github: + # If in a public repo, setting `lockdown: false` allows + # reading issues, pull requests and comments from 3rd-parties + # If in a private repo this has no particular effect. + lockdown: false + +safe-outputs: + mentions: false + allowed-github-references: [] + create-issue: + title-prefix: "[repo-status] " + labels: [report, daily-status] + close-older-issues: true +source: githubnext/agentics/workflows/daily-repo-status.md@346204513ecfa08b81566450d7d599556807389f +engine: copilot +--- + +# Daily Repo Status + +Create an upbeat daily status report for the repo as a GitHub issue. + +## What to include + +- Recent repository activity (issues, PRs, discussions, releases, code changes) +- Progress tracking, goal reminders and highlights +- Project status and recommendations +- Actionable next steps for maintainers + +## Style + +- Be positive, encouraging, and helpful 🌟 +- Use emojis moderately for engagement +- Keep it concise - adjust length based on actual activity + +## Process + +1. Gather recent activity from the repository +2. Study the repository, its issues and its pull requests +3. Create a new GitHub issue with your findings and insights \ No newline at end of file diff --git a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift index 64e4c52a7..9af76fb37 100644 --- a/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift +++ b/Packages/TableProCore/Sources/TableProMSSQLCore/MSSQLConnectionOptions.swift @@ -9,11 +9,13 @@ public struct MSSQLConnectionOptions: Sendable, Equatable { public var schema: String public var encryptionFlag: String public var applicationName: String + public var loginTimeoutSeconds: Int public static let defaultPort = 1433 public static let defaultSchema = "dbo" public static let defaultApplicationName = "TablePro" public static let defaultEncryptionFlag = "off" + public static let defaultLoginTimeoutSeconds = 30 public init( host: String, @@ -23,7 +25,8 @@ public struct MSSQLConnectionOptions: Sendable, Equatable { database: String, schema: String = MSSQLConnectionOptions.defaultSchema, encryptionFlag: String = MSSQLConnectionOptions.defaultEncryptionFlag, - applicationName: String = MSSQLConnectionOptions.defaultApplicationName + applicationName: String = MSSQLConnectionOptions.defaultApplicationName, + loginTimeoutSeconds: Int = MSSQLConnectionOptions.defaultLoginTimeoutSeconds ) { self.host = host self.port = port @@ -33,6 +36,7 @@ public struct MSSQLConnectionOptions: Sendable, Equatable { self.schema = schema self.encryptionFlag = encryptionFlag self.applicationName = applicationName + self.loginTimeoutSeconds = loginTimeoutSeconds } } diff --git a/Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h b/Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h index 917609597..742367e5f 100644 --- a/Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h +++ b/Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h @@ -121,4 +121,7 @@ extern MHANDLEFUNC dbmsghandle(MHANDLEFUNC handler); extern char *dbversion(void); +// Global login timeout in seconds. Applies to all subsequent dblogin/dbopen calls. +extern RETCODE dbsetlogintime(int seconds); + #endif /* _SYBDB_H_ */ diff --git a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift index 67ddca560..167c355d0 100644 --- a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift +++ b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift @@ -1,3 +1,14 @@ +// +// FreeTDSConnection.swift +// TablePro +// +// Dual-ownership: compiled into BOTH the macOS MSSQLDriver plugin target +// (Plugins/MSSQLDriverPlugin/ is its FileSystemSynchronizedRootGroup) AND the +// iOS TableProMobile target (via the cross-project file reference at +// TableProMobile/TableProMobile.xcodeproj path = ../Plugins/MSSQLDriverPlugin/...). +// Edits here ship to both platforms, so keep the API neutral (no PluginKit deps). +// + import CFreeTDS import Foundation import os @@ -142,6 +153,11 @@ final class FreeTDSConnection: @unchecked Sendable { _ = dbsetlversion(login, UInt8(DBVERSION_74)) _ = dbsetlname(login, options.encryptionFlag, Int32(DBSETENCRYPT)) + // dbsetlogintime is process-global; setting before dbopen bounds this call. Concurrent + // connectSync from another FreeTDSConnection would race, but the serial connect queue and + // the brief window (cleared at function exit) keeps the cost acceptable for interactive use. + _ = dbsetlogintime(Int32(options.loginTimeoutSeconds)) + freetdsClearError(for: nil) let serverName = "\(options.host):\(options.port)" guard let proc = dbopen(login, serverName) else { diff --git a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift index 9e69ad959..6c7708518 100644 --- a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift @@ -36,7 +36,8 @@ final class MSSQLDriver: DatabaseDriver, @unchecked Sendable { password: password ?? "", database: connection.database, schema: MSSQLConnectionOptions.schema(from: connection.additionalFields), - encryptionFlag: Self.freetdsEncryptionFlag(for: connection.sslConfiguration) + encryptionFlag: Self.freetdsEncryptionFlag(for: connection.sslConfiguration), + loginTimeoutSeconds: Int(connection.additionalFields["mssqlLoginTimeout"] ?? "") ?? MSSQLConnectionOptions.defaultLoginTimeoutSeconds ) self.conn = FreeTDSConnection(options: options) self.host = connection.host diff --git a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift index fcfdd23b7..3342d8174 100644 --- a/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/ConnectionFormViewModel.swift @@ -73,7 +73,10 @@ final class ConnectionFormViewModel { username = conn.username database = conn.database sslEnabled = conn.sslEnabled - mssqlSSLMode = conn.sslConfiguration?.mode ?? .disable + // Coerce verify modes to .require: FreeTDS doesn't honor per-connection cert verification + // (MSSQLSSLMapping treats verify* as "require"). Matches what the driver actually does. + let storedMode = conn.sslConfiguration?.mode ?? .disable + mssqlSSLMode = (storedMode == .verifyCa || storedMode == .verifyFull) ? .require : storedMode sshEnabled = conn.sshEnabled groupId = conn.groupId tagId = conn.tagId diff --git a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift index 1712e14b9..e042797a9 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionFormView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionFormView.swift @@ -57,11 +57,12 @@ struct ConnectionFormView: View { if viewModel.type != .sqlite { Section { if viewModel.type == .mssql { + // FreeTDS db-lib only honors on/off encryption (DBSETENCRYPT). Per-connection + // cert chain verification is not exposed, so only Disabled and Required are listed. + // See Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift for the FreeTDS contract. Picker(String(localized: "SSL Mode"), selection: $viewModel.mssqlSSLMode) { Text(String(localized: "Disabled")).tag(SSLConfiguration.SSLMode.disable) Text(String(localized: "Required")).tag(SSLConfiguration.SSLMode.require) - Text(String(localized: "Verify CA")).tag(SSLConfiguration.SSLMode.verifyCa) - Text(String(localized: "Verify Full")).tag(SSLConfiguration.SSLMode.verifyFull) } } else { Toggle("SSL", isOn: $viewModel.sslEnabled) diff --git a/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift b/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift index 4619b6a2a..189f3a976 100644 --- a/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift +++ b/TableProMobile/TableProMobileTests/Drivers/MSSQLDriverTests.swift @@ -202,7 +202,7 @@ final class MSSQLDriverTests: XCTestCase { port: 1433, username: "sa", database: "master", - additionalFields: ["mssqlSchema": "dbo"] + additionalFields: ["mssqlSchema": "dbo", "mssqlLoginTimeout": "5"] ) let badDriver = MSSQLDriver(connection: connection, password: "x") do { @@ -219,6 +219,36 @@ final class MSSQLDriverTests: XCTestCase { // building FreeTDS with custom OpenSSL callbacks or trusting the certificate via machine-wide // freetds.conf, neither of which is per-connection. Out of scope for this driver. + func testSortedWithFilterCombinesOrderAndWhere() async throws { + let driver = try XCTUnwrap(driver) + _ = try await driver.execute(query: """ + IF OBJECT_ID('dbo.tp_filter_sort', 'U') IS NOT NULL DROP TABLE dbo.tp_filter_sort; + CREATE TABLE dbo.tp_filter_sort (id INT PRIMARY KEY, kind NVARCHAR(10), score INT); + INSERT INTO dbo.tp_filter_sort VALUES + (1, N'a', 10), (2, N'b', 5), (3, N'a', 20), (4, N'a', 15), (5, N'b', 1); + """) + let result = try await driver.execute(query: """ + SELECT id, kind, score FROM [dbo].[tp_filter_sort] + WHERE kind = N'a' + ORDER BY score DESC + OFFSET 0 ROWS FETCH NEXT 2 ROWS ONLY + """) + XCTAssertEqual(result.rows.count, 2) + XCTAssertEqual(result.rows.first?[0], "3") + XCTAssertEqual(result.rows.last?[0], "4") + } + + func testSelectTopOneCellLookup() async throws { + let driver = try XCTUnwrap(driver) + _ = try await driver.execute(query: """ + IF OBJECT_ID('dbo.tp_lookup', 'U') IS NOT NULL DROP TABLE dbo.tp_lookup; + CREATE TABLE dbo.tp_lookup (id INT PRIMARY KEY, payload NVARCHAR(MAX)); + INSERT INTO dbo.tp_lookup VALUES (7, N'hello'); + """) + let result = try await driver.execute(query: "SELECT TOP 1 [payload] FROM [dbo].[tp_lookup] WHERE [id] = 7") + XCTAssertEqual(result.rows.first?.first, "hello") + } + func testMultiColumnForeignKey() async throws { let driver = try XCTUnwrap(driver) _ = try await driver.execute(query: """ From 6bc4ae43c31ee8bd1af94e60c8c2d109d37a9d24 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 13 May 2026 19:26:52 +0700 Subject: [PATCH 5/5] ci(ios): bust cache key on FreeTDS stub change; nonisolated FreeTDSConnection --- .github/workflows/ios-tests.yml | 4 +++- Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift | 5 ++++- TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ios-tests.yml b/.github/workflows/ios-tests.yml index e0e900d1c..61d7ee7bd 100644 --- a/.github/workflows/ios-tests.yml +++ b/.github/workflows/ios-tests.yml @@ -57,7 +57,9 @@ jobs: uses: actions/cache@v4 with: path: Libs - key: ${{ runner.os }}-libs-${{ hashFiles('Libs/checksums.sha256') }} + # Include the FreeTDS stub header in the cache key so iOS xcframework refreshes + # whenever the C bridge surface (e.g. new symbol declarations) changes. + key: ${{ runner.os }}-libs-${{ hashFiles('Libs/checksums.sha256', 'Plugins/MSSQLDriverPlugin/CFreeTDS/include/sybdb.h') }} - name: Download static libraries env: diff --git a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift index 167c355d0..40b32b06b 100644 --- a/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift +++ b/Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift @@ -113,7 +113,10 @@ private func freetdsDispatchAsync( } } -final class FreeTDSConnection: @unchecked Sendable { +// nonisolated so this file compiles cleanly under TableProMobile's +// SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor build setting. The class manages its own +// thread safety via a private serial DispatchQueue and NSLock; no main-actor hop needed. +nonisolated final class FreeTDSConnection: @unchecked Sendable { private var dbproc: UnsafeMutablePointer? private let queue: DispatchQueue private let options: MSSQLConnectionOptions diff --git a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift index 6c7708518..36a1d0413 100644 --- a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift @@ -4,7 +4,7 @@ import TableProModels import TableProMSSQLCore private extension MSSQLRawResult { - func toQueryResult(executionTime: TimeInterval) -> QueryResult { + nonisolated func toQueryResult(executionTime: TimeInterval) -> QueryResult { let columnInfos = columns.enumerated().map { idx, col in ColumnInfo(name: col.name, typeName: col.type.canonicalName, ordinalPosition: idx) }