diff --git a/CHANGELOG.md b/CHANGELOG.md index 36eafe6d..12c55d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.8.0] - Unreleased ### Added +- Production Decomposition mode allows controlling interoperability productions as individual files for each host (#469) +- Added saving settings as system default for new namespaces (#535) - Added filtering through branch names in UI (#615) ## [2.7.1] - 2024-11-13 diff --git a/cls/SourceControl/Git/API.cls b/cls/SourceControl/Git/API.cls index c85543ac..e77ec05f 100644 --- a/cls/SourceControl/Git/API.cls +++ b/cls/SourceControl/Git/API.cls @@ -75,4 +75,11 @@ ClassMethod MapEverywhere() Quit ##class(SourceControl.Git.Installer).MapEverywhere() } +/// Run to baseline all interoperability productions in the namespace to source control. +/// This should be done after changing the value of the "decompose productions" setting. +ClassMethod BaselineProductions() +{ + do ##class(SourceControl.Git.Util.Production).BaselineProductions() +} + } diff --git a/cls/SourceControl/Git/Extension.cls b/cls/SourceControl/Git/Extension.cls index 1b5d6ce4..d9f4287a 100644 --- a/cls/SourceControl/Git/Extension.cls +++ b/cls/SourceControl/Git/Extension.cls @@ -327,22 +327,44 @@ Method OnBeforeTimestamp(InternalName As %String) Method OnAfterSave(InternalName As %String, Object As %RegisteredObject = {$$$NULLOREF}) As %Status { set sc = $$$OK + quit:$get(%gscSkipSaveHooks) sc try { set InternalName = ##class(SourceControl.Git.Utils).NormalizeInternalName(.InternalName,.fromWebApp,.fullExternalName) set context = ##class(SourceControl.Git.PackageManagerContext).ForInternalName(InternalName) - if ##class(SourceControl.Git.Utils).IsNamespaceInGit() && ..IsInSourceControl(InternalName) { - if fromWebApp { - if fullExternalName = ##class(SourceControl.Git.Utils).FullExternalName(InternalName) { - // Reimport item into database - $$$ThrowOnError(##class(SourceControl.Git.Utils).ImportItem(InternalName,,1,1)) + if ##class(SourceControl.Git.Utils).IsNamespaceInGit() { + // If this is a production class and production decomposition is enabled, call recursively on all modified production items. + if ##class(SourceControl.Git.Utils).ItemIsProductionToDecompose(InternalName) { + do ##class(SourceControl.Git.Production).GetModifiedItemsAfterSave(InternalName, .productionItems) + set key = $order(productionItems("")) + while (key '= "") { + if productionItems(key) = "D" { + set itemFilename = ..FullExternalName(key) + if ##class(SourceControl.Git.Utils).IsInSourceControl(key) && ##class(%File).Exists(itemFilename) { + $$$ThrowOnError(##class(SourceControl.Git.Change).AddDeletedToUncommitted(itemFilename, key)) + $$$ThrowOnError(##class(SourceControl.Git.Utils).DeleteExternalFile(key)) + } + } elseif '..IsInSourceControl(key) { + $$$ThrowOnError(##class(SourceControl.Git.Utils).AddToSourceControl(key)) + } else { + $$$ThrowOnError(..OnAfterSave(key)) + } + set key = $order(productionItems(key)) } - } else { - set filename = ##class(SourceControl.Git.Utils).FullExternalName(InternalName) - $$$ThrowOnError(##class(SourceControl.Git.Utils).RemoveRoutineTSH(InternalName)) - set forceExport = (InternalName'= "") && ($data(..Modified(InternalName))) - $$$ThrowOnError(##class(SourceControl.Git.Utils).ExportItem(InternalName,,forceExport)) - if '##class(SourceControl.Git.Change).IsUncommitted(filename) { - $$$ThrowOnError(##class(SourceControl.Git.Change).SetUncommitted(filename, "edit", InternalName, $username, "", 1, "", "", 0)) + } + if ..IsInSourceControl(InternalName) { + if fromWebApp { + if fullExternalName = ##class(SourceControl.Git.Utils).FullExternalName(InternalName) { + // Reimport item into database + $$$ThrowOnError(##class(SourceControl.Git.Utils).ImportItem(InternalName,,1,1)) + } + } else { + set filename = ##class(SourceControl.Git.Utils).FullExternalName(InternalName) + $$$ThrowOnError(##class(SourceControl.Git.Utils).RemoveRoutineTSH(InternalName)) + set forceExport = (InternalName'= "") && ($data(..Modified(InternalName))) + $$$ThrowOnError(##class(SourceControl.Git.Utils).ExportItem(InternalName,,forceExport)) + if '##class(SourceControl.Git.Change).IsUncommitted(filename) { + $$$ThrowOnError(##class(SourceControl.Git.Change).SetUncommitted(filename, "edit", InternalName, $username, "", 1, "", "", 0)) + } } } } @@ -400,9 +422,41 @@ Method ExternalName(InternalName As %String) As %String quit ##class(SourceControl.Git.Utils).ExternalName(InternalName) } +ClassMethod FullExternalName(InternalName As %String) As %String +{ + quit ##class(SourceControl.Git.Utils).FullExternalName(InternalName) +} + Method IsReadOnly(InternalName As %String) As %Boolean { - quit ##class(SourceControl.Git.Utils).Locked() + set settings = ##class(SourceControl.Git.Settings).%New() + quit (##class(SourceControl.Git.Utils).Locked() + && '$get(^IRIS.Temp("sscProd",$job,"bypassLock"))) + || (##class(SourceControl.Git.Utils).ItemIsProductionToDecompose($get(InternalName)) + && 'settings.decomposeProdAllowIDE + && '##class(SourceControl.Git.Production).IsEnsPortal()) +} + +/// Called before the item is saved to the database it is passed +/// a reference to the current temporary storage of this item so that it +/// can be modified before the save completes. If you quit with an error +/// value then it will abort the save. +Method OnBeforeSave(InternalName As %String, Location As %String = "", Object As %RegisteredObject = {$$$NULLOREF}) As %Status +{ + set st = $$$OK + if ##class(SourceControl.Git.Utils).ItemIsProductionToDecompose(InternalName) { + do ##class(SourceControl.Git.Production).GetModifiedItemsBeforeSave(InternalName,,.productionItems) + set key = $order(productionItems("")) + while (key '= "") { + // if any modified items in this production class are checked out by a different user, fail the check. + set st = ..GetStatus(key, .IsInSourceControl, .Editable, .IsCheckedOut, .UserCheckedOut) + quit:$$$ISERR(st) + if 'Editable set st = $$$ERROR($$$GeneralError,"Item is checked out by another user: "_UserCheckedOut) + quit:$$$ISERR(st) + set key = $order(productionItems(key)) + } + } + return st } /// Check the status of the given item @@ -411,7 +465,7 @@ Method IsReadOnly(InternalName As %String) As %Boolean Method GetStatus(ByRef InternalName As %String, ByRef IsInSourceControl As %Boolean, ByRef Editable As %Boolean, ByRef IsCheckedOut As %Boolean, ByRef UserCheckedOut As %String) As %Status { set context = ##class(SourceControl.Git.PackageManagerContext).ForInternalName(.InternalName) - set Editable='..IsReadOnly(),IsCheckedOut=1,UserCheckedOut="" + set Editable='..IsReadOnly($get(InternalName)),IsCheckedOut=1,UserCheckedOut="" set filename=##class(SourceControl.Git.Utils).FullExternalName(.InternalName) set IsInSourceControl=(filename'=""&&($$$FileExists(filename))) if filename="" quit $$$OK diff --git a/cls/SourceControl/Git/File.cls b/cls/SourceControl/Git/File.cls index 951b8446..9ace3374 100644 --- a/cls/SourceControl/Git/File.cls +++ b/cls/SourceControl/Git/File.cls @@ -35,8 +35,23 @@ ClassMethod ExternalNameToInternalName(ExternalName As %String) As %String } new %SourceControl //don't trigger source hooks with this test load to get the Name set sc=$system.OBJ.Load(ExternalName,"-d",,.outName,1) - if (($data(outName)=1) || ($data(outName) = 11 && ($order(outName(""),-1) = $order(outName(""))))) && ($zconvert(##class(SourceControl.Git.Utils).Type(outName),"U") '= "CSP") { + set itemIsPTD = 0 + if $data(outName) = 11 { + set key = $order(outName("")) + while (key '= "") { + if ($zconvert($piece(outName,".",*),"U") = "PTD") { + set itemIsPTD = 1 + quit + } + set key = $order(outName(key)) + } + } + if itemIsPTD && ##class(%Library.EnsembleMgr).IsEnsembleNamespace() { + do ##class(SourceControl.Git.Production).ParseExternalName($replace(ExternalName,"\","/"),.internalName) + } elseif (($data(outName)=1) || ($data(outName) = 11 && ($order(outName(""),-1) = $order(outName(""))))) && ($zconvert(##class(SourceControl.Git.Utils).Type(outName),"U") '= "CSP") { set internalName = outName + } + if (internalName '= "") { set inst.InternalName = internalName $$$ThrowOnError(inst.%Save()) } @@ -68,4 +83,4 @@ Storage Default %Storage.Persistent } -} \ No newline at end of file +} diff --git a/cls/SourceControl/Git/PackageManagerContext.cls b/cls/SourceControl/Git/PackageManagerContext.cls index c75ea4b0..103f4722 100644 --- a/cls/SourceControl/Git/PackageManagerContext.cls +++ b/cls/SourceControl/Git/PackageManagerContext.cls @@ -18,6 +18,10 @@ Method InternalNameSet(InternalName As %String = "") As %Status set InternalName = ##class(SourceControl.Git.Utils).NormalizeInternalName(InternalName) if (InternalName '= i%InternalName) { set i%InternalName = InternalName + if (InternalName = ##class(SourceControl.Git.Settings.Document).#INTERNALNAME) { + // git source control settings document is never in an IPM context + quit $$$OK + } if $$$comClassDefined("%IPM.ExtensionBase.Utils") { set ..Package = ##class(%IPM.ExtensionBase.Utils).FindHomeModule(InternalName,,.resourceReference) } elseif $$$comClassDefined("%ZPM.PackageManager.Developer.Extension.Utils") { @@ -50,4 +54,4 @@ Method Dump() write !?4,"Git-enabled? ",$select(..IsInGitEnabledPackage:"Yes",1:"No"),! } -} \ No newline at end of file +} diff --git a/cls/SourceControl/Git/Production.cls b/cls/SourceControl/Git/Production.cls new file mode 100644 index 00000000..1ffbe553 --- /dev/null +++ b/cls/SourceControl/Git/Production.cls @@ -0,0 +1,531 @@ +/// This is a replica of %Studio.SourceControl.Production for backwards compatibility with older IRIS versions. +Class SourceControl.Git.Production Extends %RegisteredObject +{ + +/// Exports settings for a given Production and each Config Item from +/// the ProductionDefinition as separate XMLs. These are exported to +/// the appropriate file based on nameMethod of the source control class +ClassMethod ExportProductionDefinitionShards(productionClass As %String, nameMethod As %String, Output internalNames) As %Status +{ + Set sc = ..ExportProductionSettings(productionClass, nameMethod, .internalName) + If $$$ISERR(sc) { + Return sc + } + Set internalNames(internalName) = 1 + + // next, export each item to a separate file + Set rs = ..ExecDirectNoPriv( + "select Name, ClassName from Ens_Config.Item where Production = ?" + , productionClass + ) + Throw:rs.%SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(rs.%SQLCODE,rs.%Message) + While rs.%Next() { + Set ptdName = "" + Set item = ##class(Ens.Config.Production).OpenItemByConfigName(productionClass _ "||" _ rs.Name _ "|" _ rs.ClassName) + If $isobject(item) { + Set sc = ..ExportConfigItemSettings(productionClass, item, nameMethod, .internalName) + If $$$ISERR(sc) { + Return sc + } + Set internalNames(internalName) = 1 + } + } + Return $$$OK +} + +/// Removes settings for a given Production and each Config Item from +/// source control. This marks each of them for delete and is triggered +/// by deleting a decomposed Production. +ClassMethod DeleteProductionDefinitionShards(productionClass As %String, deleteMethod As %String, nameMethod As %String) As %Status +{ + set sc = $$$OK + if '$isobject($get(%SourceControl)) { + new %SourceControl + $$$ThrowOnError(##class(%Studio.SourceControl.Interface).SourceControlCreate()) + } + set settingsPTD = ..CreateInternalName(productionClass,,,1) + set sourceControlClass = ##class(%Studio.SourceControl.Interface).SourceControlClassGet() + set settingsPTDFile = $classmethod(sourceControlClass, nameMethod, settingsPTD) + // if the Production settings PTD exists, delete all PTDs for this Production + if ##class(%File).Exists(settingsPTDFile) { + set ptdDir = ##class(%File).GetDirectory(settingsPTDFile) + set statement = ##class(%SQL.Statement).%New() + try { + // execute without priv checking if possible on this IRIS version + set sc = statement.%PrepareClassQuery("%File","FileSet",0) + } catch err { + set sc = statement.%PrepareClassQuery("%File","FileSet") + } + quit:$$$ISERR(sc) + set rs = statement.%Execute(ptdDir, "*.xml") + throw:rs.%SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(rs.%SQLCODE,rs.%Message) + while rs.%Next(.sc) { + quit:$$$ISERR(sc) + set ptdFilename = rs.Data("Name") + set sc = ##class(%Studio.SourceControl.Production).ParseExternalName(ptdFilename, .ptdInternalName) + quit:$$$ISERR(sc) + // TODO: Consider reverting delete if any ptd is not editable by current user + set sc = $method(%SourceControl, deleteMethod, ptdInternalName) + quit:$$$ISERR(sc) + } + } + return sc +} + +/// Exports a Studio project including both the provided PTD and export notes for the PTD +ClassMethod ExportProjectForPTD(productionClass, ptdName, exportPath) As %Status +{ + set st = $$$OK + try { + set project = ##class(%Studio.Project).%New() + set project.Name = $replace($replace(ptdName,".","_"),":","-") + kill projContentsList + set projContentsList(ptdName _ ".PTD") = "" + $$$ThrowOnError(##class(Ens.Deployment.Utils).CreateExportNotesPTD(project.Name,productionClass,,.projContentsList,0,.exportNotesPTDName)) + // strip items from export notes that break our diff + set st = ##class(Ens.Util.ProjectTextDocument).GetStream(.notesStream, exportNotesPTDName) + quit:$$$ISERR(st) + set newNotesStream = ##class(%Stream.GlobalCharacter).%New() + while 'notesStream.AtEnd { + set line = notesStream.ReadLine() + if $match(line, "^<(Machine|Instance|Namespace|Username)>.*") { + // remove these + } elseif $match(line, "^.*") { + // dummy timestamp for source control hooks to work properly + set st = newNotesStream.WriteLine("1841-01-01 00:00:00.000") + quit:$$$ISERR(st) + } else { + set st = newNotesStream.WriteLine(line) + quit:$$$ISERR(st) + } + } + do:##class(%RoutineMgr).Exists(exportNotesPTDName_".PTD") ##class(%RoutineMgr).Delete(exportNotesPTDName_".PTD") + set st = ##class(Ens.Util.ProjectTextDocument).Create(newNotesStream, exportNotesPTDName, "Export Notes for export "_project.Name) + quit:$$$ISERR(st) + // Internal/External naming logic relies on Export Notes being added to project first. If this is changed check for dependencies + do project.AddItem(exportNotesPTDName_".PTD") + do project.AddItem(ptdName_".PTD") + $$$ThrowOnError(project.%Save()) + set projContentsList(exportNotesPTDName_".PTD") = "" + set projContentsList(project.Name_".PRJ") = "" + $$$ThrowOnError($System.OBJ.Export(.projContentsList, exportPath, "/diffexport=1")) + // remove the LastModified timestamp from the exported file + set fileStream = ##class(%Stream.FileCharacter).%OpenId(exportPath,,.st) + $$$ThrowOnError(st) + set fileCopy = ##class(%Stream.TmpCharacter).%New() + set timestampFound = 0 + while 'fileStream.AtEnd { + set line = fileStream.ReadLine() + set regex = ##class(%Regex.Matcher).%New("\") + if 'timestampFound && regex.Match(line) { + set timestampFound = 1 + set timestamp = regex.Group(1) + set line = $replace(line, timestamp, "1841-01-01 00:00:00.0000000") + } + do fileCopy.WriteLine(line) + } + $$$ThrowOnError(fileStream.CopyFromAndSave(fileCopy)) + } catch err { + set st = err.AsStatus() + } + if $IsObject(project) { + set st = $$$ADDSC(st,##class(%Studio.Project).%DeleteId(project.Name)) + } + return st +} + +/// Creates and exports a PTD item for a given internal name, either a single config item +/// or the production settings. +ClassMethod ExportPTD(internalName As %String, nameMethod) As %Status +{ + Set name = $Piece(internalName,".",1,$Length(internalName,".")-1) + Set $ListBuild(productionName, itemName) = $ListFromString(name, "||") + Set $ListBuild(itemName, itemClassName) = $ListFromString(itemName, "|") + Set sc = $$$OK + If $Piece($Piece(name,"||",2),"|",2) = "" { + Set sc = ..ExportProductionSettings(productionName, nameMethod) + } Else { + Set configItemName = productionName_"||"_$Piece(itemName, "Settings-", 2)_"|"_itemClassName + Set item = ##class(Ens.Config.Production).OpenItemByConfigName(configItemName) + If $IsObject(item) { + Set sc = ..ExportConfigItemSettings(productionName, item, nameMethod) + } + } + Return sc +} + +/// Imports a PTD into a produciton given an external name and produciton name +ClassMethod ImportPTD(externalName As %String, productionName As %String) As %Status +{ + try { + set ^IRIS.Temp("sscProd",$job,"bypassLock") = 1 + set rollbackFile = ##class(%File).TempFilename() + set sc = ##class(Ens.Deployment.Deploy).DeployCode(externalName,productionName,0,rollbackFile) + do ##class(%File).Delete(rollbackFile) + kill ^IRIS.Temp("sscProd",$job,"bypassLock") + } catch err { + kill ^IRIS.Temp("sscProd",$job,"bypassLock") + set sc = err.AsStatus() + } + return sc +} + +/// Imports all PTDs within a given directory. Also recursively imports from all subdirectories +ClassMethod ImportPTDsDir(directory As %String, isDecompMethod As %String = "") As %Status +{ + set sc = $$$OK + set rs = ##class(%ResultSet).%New("%File:FileSet") + $$$ThrowOnError(rs.Execute(directory, "*.xml", "", 1)) + throw:rs.%SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(rs.%SQLCODE, rs.%Message) + while rs.Next() { + set path = rs.Data("Name") + set type = rs.Data("Type") + if type = "D" { + set sc = ..ImportPTDsDir(path) + } else { + $$$ThrowOnError(..ParseExternalName(path, .internalName, .prodName)) + set srcCtrlCls = ##class(%Studio.SourceControl.Interface).SourceControlClassGet() + set isDecomp = $select(isDecompMethod="":1, 1:$classMethod(srcCtrlCls, isDecompMethod, internalName)) + if isDecomp { + set filename = ##class(%File).GetFilename(path) + if ($extract(filename) = "P") && '$$$comClassDefined(prodName) { + $$$ThrowOnError(..CreateProduction(prodName)) + } + set sc = ..ImportPTD(path, prodName) + } + } + } + return sc +} + +/// Export a single Production Config Item. For a given Ens.Config.Item, the +/// exports the PTD for this item to the file system under the directory specified +ClassMethod ExportConfigItemSettings(productionClass As %String, item As %RegisteredObject, nameMethod As %String, Output internalName As %String) As %Status +{ + set internalName = ..CreateInternalName(productionClass, item.Name, item.ClassName, 0) + Set externalName = $ClassMethod(##class(%Studio.SourceControl.Interface).SourceControlClassGet(), nameMethod, internalName) + Set filename = ##class(%File).NormalizeFilename(externalName) + set st = ##class(Ens.Deployment.Utils).CreatePTDFromItem(.item, .ptdName) + $$$QuitOnError(st) + set st = ..ExportProjectForPTD(productionClass, ptdName, filename) + $$$QuitOnError(st) + Return st +} + +/// Exports the Production settings from ProductionDefinition given the Production +/// class name +ClassMethod ExportProductionSettings(productionClass As %String, nameMethod As %String, Output internalName As %String) As %Status +{ + set internalName = ..CreateInternalName(productionClass,,,1) + Set class = ##class(%Dictionary.CompiledClass).%OpenId(productionClass) + Set sc = ##class(Ens.Deployment.Utils).CreatePTDFromProduction(class, .ptdName) + If $$$ISERR(sc) { + Return sc + } + Set externalName = $ClassMethod(##class(%Studio.SourceControl.Interface).SourceControlClassGet(), nameMethod, internalName) + Set filename = ##class(%File).NormalizeFilename(externalName) + set sc = ..ExportProjectForPTD(productionClass, ptdName, filename) + Return sc +} + +ClassMethod GetModifiedItemsBeforeSave(internalName, Location, Output modifiedItems) +{ + kill modifiedItems + set productionName = $piece(internalName,".",1,*-1) + set productionConfig = ##class(Ens.Config.Production).%OpenId(productionName) + if ..IsEnsPortal() { + // If editing from SMP, get the modified items by looking at %IsModified on the items in the production in memory. + // No way to know if an item has been added or deleted, so ignore it. + if $isobject(productionConfig) { + set modifiedItem = $$$NULLOREF + for i=1:1:productionConfig.Items.Count() { + set item = productionConfig.Items.GetAt(i) + if item.%IsModified() { + set modifiedItem = item + quit + } + for j=1:1:item.Settings.Count() { + set setting = item.Settings.GetAt(j) + if $isobject(setting) && setting.%IsModified() { + set modifiedItem = item + quit + } + } + } + set modifiedInternalName = "" + if $isobject(modifiedItem) { + set modifiedInternalName = ..CreateInternalName(productionName, modifiedItem.Name, modifiedItem.ClassName, 0) + } else { + // cannot check %IsModified on production config settings because they are not actually modified at this point. + // workaround: just assume any change not to a specific item is to the production settings + set modifiedInternalName = ..CreateInternalName(productionName,,,1) + } + } + if ($get(modifiedInternalName) '= "") { + set modifiedItems(modifiedInternalName) = "M" + } + } else { + // FUTURE: get the actually modified items by comparing the XDATA in Location with the XDATA in the compiled class + // If making changes from Studio, list every item in the production. + if $isobject(productionConfig) { + set modifiedItems(..CreateInternalName(productionName,,,1)) = "M" + for i=1:1:productionConfig.Items.Count() { + set item = productionConfig.Items.GetAt(i) + set modifiedItems(..CreateInternalName(productionName, item.Name, item.ClassName, 0)) = "M" + } + } + } + // populate data for use in OnAfterSave + kill ^IRIS.Temp("sscProd",$job,"modifiedItems") + merge ^IRIS.Temp("sscProd",$job,"modifiedItems") = modifiedItems + // FUTURE: use a percent variable or PPG instead + kill ^IRIS.Temp("sscProd",$job,"items") + set rs = ..ExecDirectNoPriv( + "select Name, ClassName from Ens_Config.Item where Production = ?" + , productionName) + throw:rs.%SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(rs.%SQLCODE,rs.%Message) + while rs.%Next() { + set ^IRIS.Temp("sscProd",$job,"items",$listbuild(rs.Name, rs.ClassName)) = 1 + } +} + +ClassMethod GetModifiedItemsAfterSave(internalName, Output modifiedItems) +{ + kill modifiedItems + set productionName = $piece(internalName,".",1,*-1) + if ..IsEnsPortal() { + // If adding/deleting from SMP, get the modified items by comparing items in temp global with items now + set rs = ..ExecDirectNoPriv( + "select Name, ClassName from Ens_Config.Item where Production = ?" + , productionName) + throw:rs.%SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(rs.%SQLCODE,rs.%Message) + while rs.%Next() { + if '$get(^IRIS.Temp("sscProd",$job,"items", $listbuild(rs.Name, rs.ClassName))) { + set itemInternalName = ..CreateInternalName(productionName, rs.Name, rs.ClassName, 0) + set modifiedItems(itemInternalName) = "A" + } + kill ^IRIS.Temp("sscProd",$job,"items", $listbuild(rs.Name, rs.ClassName)) + } + set key = $order(^IRIS.Temp("sscProd",$job,"items","")) + while (key '= "") { + set itemInternalName = ..CreateInternalName(productionName, $listget(key,1), $listget(key,2), 0) + set modifiedItems(itemInternalName) = "D" + set key = $order(^IRIS.Temp("sscProd",$job,"items",key)) + } + // If editing from SMP, get the modified items from a cache stored in OnBeforeSave. + // Only do this if there are no added/deleted items, because otherwise production settings will be incorrectly included. + if '$data(modifiedItems) { + merge modifiedItems = ^IRIS.Temp("sscProd",$job,"modifiedItems") + } + } else { + // If editing in the IDE, list every item in the production. + // FUTURE: get the actually modified items using the temp global set in OnBeforeSave + set productionConfig = ##class(Ens.Config.Production).%OpenId(productionName) + if $isobject(productionConfig) { + set modifiedItems(..CreateInternalName(productionName,,,1)) = "M" + for i=1:1:productionConfig.Items.Count() { + set item = productionConfig.Items.GetAt(i) + set modifiedItems(..CreateInternalName(productionName, item.Name, item.ClassName, 0)) = "M" + } + } + } +} + +/// Check if current CSP session is EnsPortal page +ClassMethod IsEnsPortal() As %Boolean +{ + Return $Data(%request) && '($IsObject(%request) && + ((%request.UserAgent [ "Code") || (%request.UserAgent [ "node-fetch"))) +} + +/// Perform check if Production Decomposition logic should be used for given item +ClassMethod IsProductionClass(className As %String, nameMethod As %String) As %Boolean +{ + if (className '= "") && $$$comClassDefined(className) { + return $classmethod(className, "%Extends", "Ens.Production") + } else { + // check if there exists a Production settings PTD export for ths Production + set settingsPTD = ..CreateInternalName(className,,,1) + set settingsPTDFilename = $classmethod(##class(%Studio.SourceControl.Interface).SourceControlClassGet(), nameMethod, settingsPTD) + if ##class(%File).Exists(settingsPTDFilename) { + return 1 + } + // check if there is a class export for this Production, load it for class definition + set filename = $classmethod(##class(%Studio.SourceControl.Interface).SourceControlClassGet(), nameMethod, className_".CLS") + if ##class(%File).Exists(filename) && (##class(%File).GetFileSize(filename) '= 0) { + $$$ThrowOnError($System.OBJ.Load(filename)) + } + // if Production exists as a class definition on the server, check if extending Ens.Production + set classDef = ##class(%Dictionary.ClassDefinition).%OpenId(className) + if $isobject(classDef) { + for i=1:1:classDef.XDatas.Count() { + set xdata = classDef.XDatas.GetAt(i) + if xdata.Name = "ProductionDefinition" return 1 + } + } + } + return 0 +} + +/// Given a file name for a PTD item, returns a suggested internal name. This method assumes that the file exists on disk. +ClassMethod ParseExternalName(externalName, Output internalName = "", Output productionName = "") As %Status +{ + set sc = $$$OK + set extNameNormalized = $replace(externalName, "\", "/") + set file = $piece(extNameNormalized, "/", *) + if $extract(file,1,9) = "ProdStgs-" { + set productionName = $replace($extract(file,10,*-4), "_", ".") + set internalName = ..CreateInternalName(productionName,,,1) + } else { + if ##class(%File).Exists(externalName) { + // Special case for Config Item Settings PTD, requires checking PTD CDATA for Item and Class name + set deployDoc = ##class(EnsLib.EDI.XML.Document).%New(externalName) + set exportNotesPTDText = $ZCVT(deployDoc.GetValueAt("/Export/Document[1]/1"),"I","XML") + set exportNotesPTD = ##class(EnsLib.EDI.XML.Document).%New(exportNotesPTDText) + set productionName = exportNotesPTD.GetValueAt("/Deployment/Creation/SourceProduction") + set settingsPTDText = $zconvert(deployDoc.GetValueAt("/Export/Document[2]/1"),"I","XML") + set settingsPTD = ##class(EnsLib.EDI.XML.Document).%New(settingsPTDText) + set itemClass = settingsPTD.GetValueAt("/Item/@ClassName") + set itemName = settingsPTD.GetValueAt("/Item/@Name") + set internalName = ..CreateInternalName(productionName, itemName, itemClass, 0) + } else { + set sc = $$$ERROR($$$GeneralError, "Item settings PTD file " _ externalName _ " does not exist. Cannot parse external name.") + } + } + return sc +} + +/// Given an internal name for a PTD item, returns a suggested filename for export, as well as: +/// - itemName: name of the configuration item +/// - productionName: name of the associated production +/// - isProdSettings: if true, this item is a production settings; if false, this item is a configuration item settings +ClassMethod ParseInternalName(internalName, noFolders As %Boolean = 0, Output fileName, Output itemName, Output itemClassName, Output productionName, Output isProdSettings As %Boolean) +{ + set name = $piece(internalName,".",1,*-1) + if 'noFolders { + set name = $replace(name,"||","/") + set $ListBuild(productionName, name) = $ListFromString(name, "/") + } + // Abbreviate "ProductionSettings" to "ProdStgs", "Settings" to "Stgs". + Set prefix = $Case($Extract(name), "P":"ProdStgs-", "S":"Stgs-", :"") + set isProdSettings = ($Extract(name) = "P") + Set name = $Piece(name,"-",2,*) + set $ListBuild(itemName, itemClassName) = $ListFromString(name, "|") + set name = prefix_$select( + $get(itemClassName) '= "": itemName_$zhex($zcrc(itemClassName,6)), + 1: name + ) + if 'noFolders { + set name = productionName _ "/" _ name + } + set fileName = $translate($replace(name, ".", "_") _ ".xml", "\", "/") +} + +/// Calculates the internal name for a decomposed production item +ClassMethod CreateInternalName(productionName = "", itemName = "", itemClassName = "", isProductionSettings As %Boolean = 0) As %String +{ + return $select( + isProductionSettings: productionName_"||ProductionSettings-"_productionName_".PTD", + 1: productionName _ "||Settings-" _ itemName _ "|" _ itemClassName _ ".PTD" + ) +} + +/// Given an external name for a PTD item, removes that item from the production. +ClassMethod RemoveItemByExternalName(externalName, nameMethod) As %Status +{ + set sc = $$$OK + set productionName = $replace($piece($replace(externalName,"\","/"),"/",*-1),"_",".") + set production = ##class(Ens.Config.Production).%OpenId(productionName,,.sc) + $$$QuitOnError(sc) + set itemToRemove = $$$NULLOREF + for i=1:1:production.Items.Count() { + set configItem = production.Items.GetAt(i) + set itemInternalName = ..CreateInternalName(productionName, configItem.Name, configItem.ClassName) + set itemExternalName = $classmethod(##class(%Studio.SourceControl.Interface).SourceControlClassGet(), nameMethod, itemInternalName) + if itemExternalName = externalName { + set itemToRemove = configItem + quit + } + } + do production.RemoveItem(itemToRemove) + return production.%Save() +} + +/// Given an internal name for a PTD item, removes that item from the production. +ClassMethod RemoveItem(internalName, noFolders As %Boolean = 0) As %Status +{ + set sc = $$$OK + try { + if '##class(%Library.EnsembleMgr).IsEnsembleNamespace() { + quit + } + do ..ParseInternalName(internalName, noFolders, , .itemName, .itemClassName, .productionName, .isProdSettings) + if 'isProdSettings { + set production = ##class(Ens.Config.Production).%OpenId(productionName,,.sc) + quit:$$$ISERR(sc) + set configItem = ##class(Ens.Config.Production).OpenItemByConfigName(productionName_"||"_itemName_"|"_itemClassName,.sc) + quit:$$$ISERR(sc) + do production.RemoveItem(configItem) + set sc = production.%Save() + quit:$$$ISERR(sc) + } + } catch err { + set sc = err.AsStatus() + } + return sc +} + +/// Given internal name for a Production Settings PTD, creates the corresponding Production +/// Class if it does not already exist in this namespace +ClassMethod CreateProduction(productionName As %String, superClasses = "") As %Status +{ + set classDef = ##class(%Dictionary.ClassDefinition).%New(productionName) + if superClasses '= "" { + set classDef.Super = superClasses + } else { + set classDef.Super = "Ens.Production" + } + set productionXData = ##class(%Dictionary.XDataDefinition).%New() + set productionXData.Name = "ProductionDefinition" + set sc = productionXData.Data.WriteLine("") + if $$$ISERR(sc) return sc + set sc = classDef.XDatas.Insert(productionXData) + if $$$ISERR(sc) return sc + set sc = classDef.%Save() + if $$$ISERR(sc) return sc + set sc = $System.OBJ.Compile(productionName) + return sc +} + +/// Given an internal name of a Production and an IRIS user, populate an array with the filenames +/// for each of their current uncommitted changes associated with the given Production +ClassMethod GetUserProductionChanges(productionName As %String, ByRef items) +{ + set sql = "SELECT InternalName, Action FROM %Studio_SourceControl.Change WHERE ChangedBy = ? AND Committed = 0 AND InternalName %STARTSWITH ?" + set rs = ..ExecDirectNoPriv(sql,$username,productionName_"||") + throw:rs.%SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(rs.%SQLCODE,rs.%Message) + while rs.%Next() { + set items(rs.InternalName) = rs.Action + } + quit $$$OK +} + +/// Executes a SQL query without privilege checking if possible on this IRIS version +ClassMethod ExecDirectNoPriv(sql, args...) As %SQL.StatementResult +{ + // once minimum version is IRIS 2021.1.3, remove and just use %ExecDirectNoPriv + try { + set rs = ##class(%SQL.Statement).%ExecDirectNoPriv(,sql,args...) + } catch err { + set rs = ##class(%SQL.Statement).%ExecDirect(,sql,args...) + } + return rs +} + +/// Returns value the Major.Minor version for this instance, so it can be used in comparison code which makes sure certain features are used in appropriate versions +ClassMethod InstanceVersion() As %Numeric [ CodeMode = expression ] +{ +$P($SYSTEM.Version.GetNumber(),".",1,2) +} + +} diff --git a/cls/SourceControl/Git/PullEventHandler/IncrementalLoad.cls b/cls/SourceControl/Git/PullEventHandler/IncrementalLoad.cls index 079aba4a..5281f01d 100644 --- a/cls/SourceControl/Git/PullEventHandler/IncrementalLoad.cls +++ b/cls/SourceControl/Git/PullEventHandler/IncrementalLoad.cls @@ -17,16 +17,20 @@ Method OnPull() As %Status if ((internalName = "") && (..ModifiedFiles(i).changeType '= "D")) { write !, ..ModifiedFiles(i).externalName, " was not imported into the database and will not be compiled. " } elseif (..ModifiedFiles(i).changeType = "D") { - set delSC = ..DeleteFile(internalName) + set delSC = ..DeleteFile(internalName, ..ModifiedFiles(i).externalName) if delSC { write !, ..ModifiedFiles(i).externalName, " was deleted." } else { write !, "WARNING: Deletion of ", ..ModifiedFiles(i).externalName, " failed." } } else { - set compilelist(internalName) = "" set nFiles = nFiles + 1 - set sc = $$$ADDSC(sc,##class(SourceControl.Git.Utils).ImportItem(internalName, 1)) + if (##class(SourceControl.Git.Utils).Type(internalName) = "ptd") { + set ptdList(internalName) = "" + } else { + set compilelist(internalName) = "" + set sc = $$$ADDSC(sc,##class(SourceControl.Git.Utils).ImportItem(internalName, 1)) + } } } @@ -35,6 +39,12 @@ Method OnPull() As %Status quit $$$OK } set sc = $$$ADDSC(sc,$system.OBJ.CompileList(.compilelist, "ck")) + // after compilation, deploy any PTD items + set key = $order(ptdList("")) + while (key '= "") { + set sc = $$$ADDSC(sc, ##class(SourceControl.Git.Utils).ImportItem(key,1)) + set key = $order(ptdList(key)) + } if $$$comClassDefined("Ens.Director") && ##class(Ens.Director).IsProductionRunning() { write !,"Updating production... " set sc = $$$ADDSC(sc,##class(Ens.Director).UpdateProduction()) @@ -43,21 +53,35 @@ Method OnPull() As %Status quit sc } -Method DeleteFile(item As %String) As %Status -{ +Method DeleteFile(item As %String = "", externalName As %String = "") As %Status +{ try { set sc = $$$OK - set type = ##class(SourceControl.Git.Utils).Type(item) + set type = $select( + ##class(SourceControl.Git.Util.Production).ItemIsPTD(externalName): "ptd", + 1: ##class(SourceControl.Git.Utils).Type(item) + ) set name = ##class(SourceControl.Git.Utils).NameWithoutExtension(item) + set settings = ##class(SourceControl.Git.Settings).%New() set deleted = 1 if type = "prj" { set sc = $system.OBJ.DeleteProject(name) }elseif type = "cls" { - set sc = $system.OBJ.Delete(item) + if ##class(SourceControl.Git.Utils).ItemIsProductionToDecompose(name) { + write !, "Production decomposition enabled, skipping delete of production class" + } else { + set sc = $system.OBJ.Delete(item) + } }elseif $listfind($listbuild("mac","int","inc","bas","mvb","mvi"), type) > 0 { set sc = ##class(%Routine).Delete(item) }elseif type = "csp" { set sc = $System.CSP.DeletePage(item) + } elseif settings.decomposeProductions && (type = "ptd") { + set normalizedFilePath = ##class(%File).NormalizeFilename(##class(SourceControl.Git.Utils).TempFolder()_externalName) + set sc = ##class(%SYSTEM.Status).AppendStatus( + ##class(SourceControl.Git.Production).RemoveItemByExternalName(normalizedFilePath,"FullExternalName"), + ##class(%Library.RoutineMgr).Delete(item) + ) }elseif ##class(SourceControl.Git.Utils).UserTypeCached(item) { set sc = ##class(%Library.RoutineMgr).Delete(item) } else { @@ -65,8 +89,10 @@ Method DeleteFile(item As %String) As %Status } if deleted && $$$ISOK(sc) { - do ##class(SourceControl.Git.Utils).RemoveRoutineTSH(item) - kill $$$TrackedItems(##class(SourceControl.Git.Utils).NormalizeExtension(item)) + if (item '= "") { + do ##class(SourceControl.Git.Utils).RemoveRoutineTSH(item) + kill $$$TrackedItems(##class(SourceControl.Git.Utils).NormalizeExtension(item)) + } } else { if +$system.Status.GetErrorCodes(sc) = $$$ClassDoesNotExist { // if something we wanted to delete is already deleted -- good! diff --git a/cls/SourceControl/Git/Settings.cls b/cls/SourceControl/Git/Settings.cls index 75e3da2f..2171b9f0 100644 --- a/cls/SourceControl/Git/Settings.cls +++ b/cls/SourceControl/Git/Settings.cls @@ -23,6 +23,12 @@ Property percentClassReplace As %String [ InitialExpression = {##class(SourceCon /// Git project settings are read-only in the web user interface Property settingsUIReadOnly As %Boolean [ InitialExpression = {##class(SourceControl.Git.Utils).SettingsUIReadOnly()} ]; +/// Interoperability productions are source-controlled under separate files for each configuration item +Property decomposeProductions As %Boolean [ InitialExpression = {##class(SourceControl.Git.Utils).DecomposeProductions()} ]; + +/// Allow editing a decomposed production class in an IDE +Property decomposeProdAllowIDE As %Boolean [ InitialExpression = {##class(SourceControl.Git.Utils).DecomposeProdAllowIDE()} ]; + /// Attribution: Git username for user ${username} Property gitUserName As %String(MAXLEN = 255) [ InitialExpression = {##class(SourceControl.Git.Utils).GitUserName()} ]; @@ -75,6 +81,36 @@ Method %OnNew() As %Status quit $$$OK } +Method SaveWithSourceControl() As %Status +{ + set sc = $$$OK + #dim %SourceControl As %Studio.SourceControl.Interface + if '$isobject($get(%SourceControl)) { + new %SourceControl + do ##class(%Studio.SourceControl.Interface).SourceControlCreate($username) + } + set gitDir = ##class(%File).NormalizeDirectory(..namespaceTemp)_".git" + set skipSourceControl = '##class(%File).DirectoryExists(gitDir) + $$$QuitOnError(..%Save()) + set internalName = ##class(SourceControl.Git.Settings.Document).#INTERNALNAME + set settingsDoc = ##class(SourceControl.Git.Settings.Document).%New(internalName) + $$$QuitOnError(settingsDoc.Load()) // reload doc to update timestamps + if 'skipSourceControl { + // Source control settings naively by only calling OnAfterSave hooks + // Future enhancement: add full source control support with settings UI + if (##class(%Studio.SourceControl.Interface).SourceControlClassGet() = ##class(SourceControl.Git.Extension).%ClassName(1)) { + if '##class(SourceControl.Git.Utils).IsInSourceControl(internalName) { + set sc = ##class(SourceControl.Git.Utils).AddToSourceControl(internalName) + $$$QuitOnError(sc) + } + } + if ($IsObject($get(%SourceControl))) { + $$$QuitOnError(%SourceControl.OnAfterSave(internalName)) + } + } + quit sc +} + Method %Save() As %Status { set sc = ..%ValidateObject() @@ -133,6 +169,12 @@ Method %Save() As %Status // update value of basicUserMode to reflect the updated setting for basicMode set ..userBasicMode = ##class(SourceControl.Git.Utils).UserBasicMode() + set @storage@("settings","decomposeProductions") = ..decomposeProductions + if (..decomposeProductions & ($data(..Mappings("PTD"))<10)) { + set ..Mappings("PTD","*") = "ptd/" // configure a default mapping for PTD items + } + set @storage@("settings","decomposeProdAllowIDE") = ..decomposeProdAllowIDE + kill @##class(SourceControl.Git.Utils).MappingsNode() merge @##class(SourceControl.Git.Utils).MappingsNode() = ..Mappings @@ -141,6 +183,53 @@ Method %Save() As %Status quit $$$OK } +Method ToDynamicObject() As %DynamicObject +{ + // uses custom methods rather than %JSON.Adaptor because Mappings multidimensional + // array is not supported + set settingsJSON = { + "pullEventClass": (..pullEventClass), + "percentClassReplace": (..percentClassReplace), + "Mappings": {} + } + do settingsJSON.%Set("decomposeProductions",..decomposeProductions,"boolean") + set k1 = $order(..Mappings("")) + while (k1 '= "") { + do settingsJSON.Mappings.%Set(k1, {}) + set k2 = $order(..Mappings(k1,"")) + while (k2 '= "") { + do settingsJSON.Mappings.%Get(k1).%Set(k2,{"directory": (..Mappings(k1,k2))}) + if $get(..Mappings(k1,k2,"NoFolders")) { + do settingsJSON.Mappings.%Get(k1).%Get(k2).%Set("noFolders", 1, "boolean") + } + set k2 = $order(..Mappings(k1, k2)) + } + set k1 = $order(..Mappings(k1)) + } + return settingsJSON +} + +Method ImportDynamicObject(pSettingsDyn As %DynamicObject) +{ + set ..pullEventClass = pSettingsDyn.%Get("pullEventClass") + set ..percentClassReplace = pSettingsDyn.%Get("percentClassReplace") + set ..decomposeProductions = pSettingsDyn.%Get("decomposeProductions") + kill ..Mappings + set mappingsDyn = pSettingsDyn.%Get("Mappings", {}) + set i1 = mappingsDyn.%GetIterator() + while i1.%GetNext(.k1, .v1) { + set i2 = v1.%GetIterator() + while i2.%GetNext(.k2, .v2) { + set ..Mappings(k1, k2) = v2.%Get("directory") + if v2.%Get("noFolders") { + set ..Mappings(k1, k2, "NoFolders") = 1 + } else { + kill ..Mappings(k1, k2, "NoFolders") + } + } + } +} + ClassMethod CreateNamespaceTempFolder() As %Status { set storage = ##class(SourceControl.Git.Utils).#Storage @@ -154,6 +243,7 @@ ClassMethod CreateNamespaceTempFolder() As %Status ClassMethod Configure() As %Boolean [ CodeMode = objectgenerator ] { do %code.WriteLine(" set inst = ..%New()") + do %code.WriteLine(" do inst.RetrieveDefaults()") set defaultPromptFlag = $$$DisableBackupCharMask + $$$TrapCtrlCMask + $$$EnableQuitCharMask + $$$DisableHelpCharMask + $$$DisableHelpContextCharMask + $$$TrapErrorMask set property = "" for { @@ -217,7 +307,7 @@ ClassMethod Configure() As %Boolean [ CodeMode = objectgenerator ] do %code.WriteLine(" set inst."_property_" = value") } - do %code.WriteLine(" $$$ThrowOnError(inst.%Save())") + do %code.WriteLine(" $$$ThrowOnError(inst.SaveWithSourceControl())") do %code.WriteLine(" write !,""Settings saved.""") do %code.WriteLine(" do inst.OnAfterConfigure()") do %code.WriteLine(" quit 1") @@ -344,8 +434,6 @@ Method OnAfterConfigure() As %Boolean set workMgr = $System.WorkMgr.%New("") $$$ThrowOnError(workMgr.Queue("##class(SourceControl.Git.Utils).Init")) $$$ThrowOnError(workMgr.WaitForComplete()) - - do ##class(SourceControl.Git.Utils).EmptyInitialCommit() } elseif (value = 2) { set response = ##class(%Library.Prompt).GetString("Git remote URL (note: if authentication is required, use SSH, not HTTPS):",.remote,,,,defaultPromptFlag) if (response '= $$$SuccessResponse) { @@ -358,6 +446,8 @@ Method OnAfterConfigure() As %Boolean set workMgr = $System.WorkMgr.%New("") $$$ThrowOnError(workMgr.Queue("##class(SourceControl.Git.Utils).Clone",remote)) $$$ThrowOnError(workMgr.WaitForComplete()) + // export settings file without committing + $$$ThrowOnError(..SaveWithSourceControl()) } } } @@ -394,4 +484,25 @@ Method ConfigureBinPath(ByRef path As %String) As %Boolean return 1 } +Method RetrieveDefaults() As %Boolean +{ + do ##class(%zpkg.isc.sc.git.Defaults).GetDefaultSettings(.settings) + set iterator = settings.%GetIterator() + while iterator.%GetNext(.key, .value) { + set $property($this,key) = value + } + return $$$OK +} + +Method SaveDefaults() As %Boolean +{ + set defaults = {} + set items = $lb("gitBinPath", "pullEventClass", "percentClassReplace", "environmentName", "systemBasicMode", "defaultMergeBranch", "mappedItemsReadOnly", "compileOnImport") + for i=1:1:$LISTLENGTH(items) { + set property = $listget(items,i) + do defaults.%Set(property, $property($this, property)) + } + return ##class(%zpkg.isc.sc.git.Defaults).SetDefaultSettings(defaults) +} + } diff --git a/cls/SourceControl/Git/Settings/Document.cls b/cls/SourceControl/Git/Settings/Document.cls new file mode 100644 index 00000000..896be064 --- /dev/null +++ b/cls/SourceControl/Git/Settings/Document.cls @@ -0,0 +1,108 @@ +/// Custom studio document type for Embedded Git settings that are controlled by a file +Class SourceControl.Git.Settings.Document Extends %Studio.AbstractDocument +{ + +Projection RegisterExtension As %Projection.StudioDocument(DocumentExtension = "GSC", DocumentNew = 0, DocumentType = "json"); + +Parameter INTERNALNAME = "embedded-git-config.GSC"; + +Parameter EXTERNALNAME = "embedded-git-config.json"; + +/// Return 1 if the routine 'name' exists and 0 if it does not. +ClassMethod Exists(name As %String) As %Boolean +{ + return (name = ..#INTERNALNAME) +} + +/// Load the routine in Name into the stream Code +Method Load() As %Status +{ + set sc = $$$OK + try { + set stream = ..GetCurrentStream() + $$$ThrowOnError(..Code.CopyFromAndSave(stream)) + $$$ThrowOnError(..Code.Rewind()) + do ..UpdateHash(stream) + } catch err { + set sc = err.AsStatus() + } + return sc +} + +Method GetCurrentStream() As %Stream.Object +{ + set settings = ##class(SourceControl.Git.Settings).%New() + set dynObj = settings.ToDynamicObject() + set formatter = ##class(%JSON.Formatter).%New() + $$$ThrowOnError(formatter.FormatToStream(dynObj, .stream)) + return stream +} + +/// Save the routine stored in Code +Method Save() As %Status +{ + set sc = $$$OK + try { + try { + set settingsJSON = ##class(%DynamicObject).%FromJSON(..Code) + } catch err { + $$$ThrowStatus($$$ERROR($$$GeneralError, "Invalid JSON")) + } + set settings = ##class(SourceControl.Git.Settings).%New() + do settings.ImportDynamicObject(settingsJSON) + set sc = settings.%Save() + quit:$$$ISERR(sc) + } catch err { + set sc = err.AsStatus() + } + return sc +} + +ClassMethod ListExecute(ByRef qHandle As %Binary, Directory As %String, Flat As %Boolean, System As %Boolean) As %Status +{ + if $g(Directory)'="" { + set qHandle="" + quit $$$OK + } + set qHandle = $listbuild(1,"") + quit $$$OK +} + +ClassMethod ListFetch(ByRef qHandle As %Binary, ByRef Row As %List, ByRef AtEnd As %Integer = 0) As %Status [ PlaceAfter = ListExecute ] +{ + set Row="", AtEnd=0 + set rownum = $lg(qHandle,1) + if rownum'=1 { + set AtEnd = 1 + } else { + set Row = $listbuild(..#INTERNALNAME,$zts-5,0,"") + set $list(qHandle,1) = 2 + } + quit $$$OK +} + +ClassMethod ListClose(ByRef qHandle As %Binary) As %Status [ PlaceAfter = ListExecute ] +{ + set qHandle = "" + quit $$$OK +} + +Method UpdateHash(stream) +{ + set stream = $Get(stream,..GetCurrentStream()) + set hash = $System.Encryption.SHA1HashStream(stream) + if $get(@##class(SourceControl.Git.Utils).#Storage@("settings","Hash")) '= hash { + set @##class(SourceControl.Git.Utils).#Storage@("settings","Hash") = hash + set @##class(SourceControl.Git.Utils).#Storage@("settings","TS") = $zdatetime($h,3) + } +} + +/// Return the timestamp of routine 'name' in %TimeStamp format. This is used to determine if the routine has +/// been updated on the server and so needs reloading from Studio. So the format should be $zdatetime($horolog,3), +/// or "" if the routine does not exist. +ClassMethod TimeStamp(name As %String) As %TimeStamp +{ + return $get(@##class(SourceControl.Git.Utils).#Storage@("settings","TS"), $zdatetime($h,3)) +} + +} diff --git a/cls/SourceControl/Git/Util/Production.cls b/cls/SourceControl/Git/Util/Production.cls new file mode 100644 index 00000000..a6692601 --- /dev/null +++ b/cls/SourceControl/Git/Util/Production.cls @@ -0,0 +1,69 @@ +Include SourceControl.Git + +/// Contains utilities for production decomposition that are specific to git-source-control +Class SourceControl.Git.Util.Production +{ + +/// Baselines all productions in this namespace from single-file to decomposed or vice versa. +ClassMethod BaselineProductions() +{ + set st = $$$OK + set settings = ##class(SourceControl.Git.Settings).%New() + set rs = ##class(%Dictionary.ClassDefinition).SubclassOfFunc("Ens.Production") + throw:rs.%SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(rs.%SQLCODE,rs.%Message) + while rs.%Next(.sc) { + $$$ThrowOnError(sc) + set productionName = rs.Name + set productionInternalName = productionName _ ".cls" + if '##class(SourceControl.Git.Utils).FileIsMapped(productionInternalName) { + if settings.decomposeProductions { + write !, "Decomposing production: " _ productionInternalName + if ##class(SourceControl.Git.Utils).IsInSourceControl(productionInternalName) { + set st = ##class(SourceControl.Git.Utils).RemoveFromSourceControl(productionInternalName) + $$$ThrowOnError(st) + } + set st = ##class(SourceControl.Git.Production).ExportProductionDefinitionShards(productionName,"FullExternalName",.itemInternalNames) + $$$ThrowOnError(st) + set key = $order(itemInternalNames("")) + while (key '= "") { + set st = ##class(SourceControl.Git.Utils).AddToSourceControl(key) + $$$ThrowOnError(st) + set key = $order(itemInternalNames(key)) + } + } else { + write !, "Recomposing production: " _ productionInternalName + set st = ##class(SourceControl.Git.Utils).AddToSourceControl(productionInternalName) + $$$ThrowOnError(st) + set key = $order(@##class(SourceControl.Git.Utils).#Storage@("items", "")) + while (key '= "") { + if $match(key,"^"_productionName_"\|\|.*\.(?i)ptd$") { + set st = ##class(SourceControl.Git.Utils).RemoveFromSourceControl(key) + $$$ThrowOnError(st) + } + set key = $order(@##class(SourceControl.Git.Utils).#Storage@("items", key)) + } + } + } + } +} + +/// Determines whether an item has type PTD based on the external name, not reliant on the file existing +ClassMethod ItemIsPTD(externalName) As %Boolean +{ + if $zconvert($piece(externalName,".",*),"l") '= "xml" { + return 0 + } + set settings = ##class(SourceControl.Git.Settings).%New() + set normFilePath = $replace(externalName,"\","/") + set key = $order($$$SourceMapping("PTD","")) + while (key '= "") { + set directory = $replace($$$SourceMapping("PTD",key), "\","/") + if $find(normFilePath, directory) = ($length(directory) + 1) { + return 1 + } + set key = $order($$$SourceMapping("PTD",key)) + } + return 0 +} + +} diff --git a/cls/SourceControl/Git/Utils.cls b/cls/SourceControl/Git/Utils.cls index ff5aeb44..bb569f35 100644 --- a/cls/SourceControl/Git/Utils.cls +++ b/cls/SourceControl/Git/Utils.cls @@ -72,6 +72,16 @@ ClassMethod SettingsUIReadOnly() As %Status [ CodeMode = expression ] $Get(@..#Storage@("settings","settingsUIReadOnly"), 0) } +ClassMethod DecomposeProductions() As %Boolean [ CodeMode = expression ] +{ +$Get(@..#Storage@("settings","decomposeProductions"), 0) +} + +ClassMethod DecomposeProdAllowIDE() As %Boolean [ CodeMode = expression ] +{ +$Get(@..#Storage@("settings","decomposeProdAllowIDE"), 0) +} + ClassMethod FavoriteNamespaces() As %String { set favNamespaces = [] @@ -361,8 +371,9 @@ ClassMethod Init() As %Status { do ..RunGitCommand("init",.errStream,.outStream) do ..PrintStreams(outStream, errStream) - - quit $$$OK + set settings = ##class(SourceControl.Git.Settings).%New() + $$$QuitOnError(settings.SaveWithSourceControl()) + quit ..Commit(##class(SourceControl.Git.Settings.Document).#INTERNALNAME,"initial commit") } ClassMethod Revert(InternalName As %String) As %Status @@ -770,7 +781,9 @@ ClassMethod AddToSourceControl(InternalName As %String) As %Status } for i=1:1:$Get(filenames) { - set FileInternalName = ##class(SourceControl.Git.Utils).NormalizeExtension(##class(SourceControl.Git.Utils).NameToInternalName(filenames(i), 0,,1)) + set ignoreNonexistent = (type '= "ptd") + set FileInternalName = ##class(SourceControl.Git.Utils).NormalizeExtension( + ##class(SourceControl.Git.Utils).NameToInternalName(filenames(i), 0,ignoreNonexistent,1)) if (FileInternalName = "") { continue } @@ -1017,7 +1030,8 @@ ClassMethod IsCspFolder(InternalName As %String) As %Boolean /// pkg -- package
/// prj -- project
/// csp -- csp-page or csp-folder. See IsCspFolder
-/// csp -- any static file from csp-folder +/// csp -- any static file from csp-folder
+/// ptd -- interoperability production configuration item ClassMethod Type(InternalName As %String) As %String { #dim extension as %String = $zconvert($piece(InternalName,".",$length(InternalName,".")),"L") @@ -1377,8 +1391,28 @@ ClassMethod ImportItem(InternalName As %String, force As %Boolean = 0, verbose A #dim fileTSH = ##class(%File).GetFileDateModified(filename) #dim sc as %Status = $$$OK - if ..IsRoutineOutdated(InternalName) || force { - if ..UserTypeCached(InternalName,.docclass,.doctype) { + set settings = ##class(SourceControl.Git.Settings).%New() + set type = ..Type(InternalName) + set imported = 1 + if ..IsRoutineOutdated(InternalName) || force || (type = "ptd"){ + if (type = "ptd") && settings.decomposeProductions && ##class(%Library.EnsembleMgr).IsEnsembleNamespace() { + if ##class(%File).Exists(filename) { + // Deployment manager should not reexport because studio project file includes timestamp + // ideally we could just new %SourceControl, but Ens portal config pages do not use %SourceControl + new %gscSkipSaveHooks + set %gscSkipSaveHooks = 1 + do ##class(SourceControl.Git.Production).ParseInternalName(InternalName,,,,,.targetProduction) + if (targetProduction '= "") && '$$$comClassDefined(targetProduction) { + set sc = ##class(SourceControl.Git.Production).CreateProduction(targetProduction) + } + if $$$ISOK(sc) { + set sc = ##class(SourceControl.Git.Production).ImportPTD(filename, targetProduction) + } + } + } elseif ..ItemIsProductionToDecompose(InternalName) { + write !, "Production decomposition enabled, skipping import of production class" + set imported = 0 + } elseif ..UserTypeCached(InternalName,.docclass,.doctype) { set routineMgr = ##class(%RoutineMgr).%OpenId(InternalName) do routineMgr.Code.Rewind() set source = ##class(%Stream.FileCharacter).%OpenId(filename,,.sc) @@ -1393,12 +1427,14 @@ ClassMethod ImportItem(InternalName As %String, force As %Boolean = 0, verbose A set sc = $system.OBJ.Load(filename,$Select(compile:"ck-l",1:"-l-d")) } } - if sc { - set sc = ..UpdateRoutineTSH(InternalName, fileTSH) - if ..Type(InternalName) = "prj" { - set sc = $$$ADDSC(sc, ..FixProjectCspReferences(InternalName)) + if $$$ISOK(sc) { + if imported { + set sc = ..UpdateRoutineTSH(InternalName, fileTSH) + if type = "prj" { + set sc = $$$ADDSC(sc, ..FixProjectCspReferences(InternalName)) + } + write !, InternalName," has been imported from ", filename } - write !, InternalName," has been imported from ", filename } else { write !, "ERROR importing" ,InternalName, ! do $system.Status.DisplayError(sc) @@ -1426,6 +1462,24 @@ ClassMethod ImportCSPFile(InternalName As %String) As %Status Quit sc } +ClassMethod ListItemsRecursively(type, fileSpec, directory, ByRef itemList) [ Private ] +{ + set files = ##class(%Library.File).FileSetFunc(directory,fileSpec,,1) + throw:files.%SQLCODE<0 ##class(%Exception.SQL).CreateFromSQLCODE(files.%SQLCODE,files.%Message) + while files.%Next() { + if (files.Type="D") { + do ..ListItemsRecursively(type, fileSpec, files.Name, .itemList) + } else { + set internalName = files.ItemName + if ($zconvert(type,"l") = "ptd") { + do ##class(SourceControl.Git.Production).ParseExternalName($translate(files.Name,"\","/"), .internalName) + } + set itemList(internalName) = "" + } + } +} + +/// Returns an array of internal names of all items in the local source control repository. ClassMethod ListItemsInFiles(ByRef itemList, ByRef err) As %Status { #define DoNotLoad 1 @@ -1442,13 +1496,11 @@ ClassMethod ListItemsInFiles(ByRef itemList, ByRef err) As %Status set mappedFilePath = ##class(%File).NormalizeFilename(mappedRelativePath, ..TempFolder()) if (##class(%File).DirectoryExists(mappedFilePath)){ - if ..UserTypeCached("foo."_mappingFileType) { - set fileSpec = "*."_$zcvt(mappingFileType,"L")_";*."_$zconvert(mappingFileType,"U") - set files = ##class(%Library.File).FileSetFunc(mappedFilePath,fileSpec) - while files.%Next() { - // Assumes flat file structure - set itemList(files.ItemName) = "" - } + if ..UserTypeCached("foo."_mappingFileType, .userTypeClass) { + set fileSpec = $select( + userTypeClass="Ens.Util.ProjectTextDocument": "*.xml;*.XML", + 1: "*."_$zcvt(mappingFileType,"L")_";*."_$zconvert(mappingFileType,"U")) + do ..ListItemsRecursively(mappingFileType, fileSpec, mappedFilePath, .itemList) } else { set res = $system.OBJ.ImportDir(mappedFilePath,,"-d",.err,1, .tempItemList, $$$DoNotLoad) merge itemList = tempItemList @@ -1604,7 +1656,7 @@ ClassMethod ExportItem(InternalName As %String, expand As %Boolean = 1, force As }elseif (type = "csp") && ..IsCspFolder(InternalName) { $$$QuitOnError(..ExportRoutinesAux(InternalName , "/", 0, force, .filenames)) }else { - if ..IsTempFileOutdated(InternalName) || force { + if (type = "ptd") || ..IsTempFileOutdated(InternalName) || force { #dim filename as %String = ..FullExternalName(InternalName, .MappingExists) if (MappingExists = 0){ write "Did not find a matching mapping for """_InternalName_""". Skipping export." @@ -1616,18 +1668,39 @@ ClassMethod ExportItem(InternalName As %String, expand As %Boolean = 1, force As write "Mapping to another database found. Skipping export" quit $$$OK } - set filenames($I(filenames)) = filename write !, "exporting new version of ", InternalName, " to ", filename - $$$QuitOnError($system.OBJ.ExportUDL(InternalName, filename,"-d/diff")) - $$$QuitOnError(..UpdateRoutineTSH(InternalName, $h)) - if '##class(SourceControl.Git.Change).IsUncommitted(filename) { - $$$ThrowOnError(##class(SourceControl.Git.Change).SetUncommitted(filename, "add", InternalName, $username, "", 1, "", "", 0)) + if (type = "ptd") { + $$$QuitOnError(##class(SourceControl.Git.Production).ExportPTD(InternalName,"FullExternalName")) + } elseif (..ItemIsProductionToDecompose(InternalName, .productionName)) { + write !, "Production decomposition enabled, skipping export of production class" + set filename = "" + } else { + $$$QuitOnError($system.OBJ.ExportUDL(InternalName, filename,"-d/diff")) + } + if (filename '= "") && ##class(%File).Exists(filename) { + set filenames($I(filenames)) = filename + $$$QuitOnError(..UpdateRoutineTSH(InternalName, $h)) + if '##class(SourceControl.Git.Change).IsUncommitted(filename) { + $$$ThrowOnError(##class(SourceControl.Git.Change).SetUncommitted(filename, "add", InternalName, $username, "", 1, "", "", 0)) + } } } } quit $$$OK } +ClassMethod ItemIsProductionToDecompose(InternalName, Output productionName) +{ + set settings = ##class(SourceControl.Git.Settings).%New() + set name = $piece(InternalName,".",1,*-1) + set decomposeProduction = settings.decomposeProductions && (..Type(InternalName) = "cls") + && ##class(SourceControl.Git.Production).IsProductionClass(name, "FullExternalName") + if decomposeProduction { + set productionName = name + } + return decomposeProduction +} + ClassMethod ExportProject(project As %String, force As %Boolean = 0, ByRef filenames) As %Status { #dim rs as %ResultSet = ##class(%ResultSet).%New("%Studio.Project:ProjectItemsList") @@ -2181,7 +2254,7 @@ ClassMethod GitStatus(ByRef files, IncludeAllFiles = 0) while $listnext(list, pointer, item) { set operation = $zstrip($extract(item, 1, 2), " +
#(..EscapeHTML(out))#
+ > + } + } catch err { + kill buffer + do err.Log() + &html<
An error occurred and has been logged to the application error log.
> } - do settings.%Save() }
@@ -209,7 +230,7 @@ body {
- +
set exists = ##class(SourceControl.Git.Utils).GitBinExists(.version) @@ -328,7 +349,7 @@ body {
- +
@@ -358,14 +379,14 @@ body {
- +
- +
@@ -386,14 +407,14 @@ body {
- +
- +
- +
@@ -423,7 +444,7 @@ body {
- +
+
+ +
+
+ + +
+
+
+
+ +
+
+ + +
+
+
@@ -545,7 +590,7 @@ body {
- +
@@ -608,7 +653,7 @@ body {
- +
+ + + +
+ Settings saved as default are bolded +
@@ -660,6 +710,17 @@ function getSocket(urlPostfix) { return new WebSocket(socketURL); } +var submitForm = function(e) { + e.preventDefault(); + e.stopPropagation(); + var proxySubmitButton = document.getElementById('proxySubmitButton'); + proxySubmitButton.value = "saveDefaults"; + var form = document.getElementById('settingsForm'); + form.submit(); +} + +document.getElementById('saveDefaults').addEventListener('click',submitForm,false); + function init() { disableActionButtons(); var root = document.getElementById("namespaceTemp").value; diff --git a/docs/production-decomposition.md b/docs/production-decomposition.md new file mode 100644 index 00000000..b951fe84 --- /dev/null +++ b/docs/production-decomposition.md @@ -0,0 +1,17 @@ +# Production Decomposition +Production Decomposition is a feature of Embedded Git that allows multiple developers to edit the same IRIS Interoperability production in the same namespace. In the past, the production class has been an obstacle preventing organizations using multi-user development namespaces from adopting source control. Production Decomposition resolves this by representing the production as a directory of files for each production item that may be edited independently. An uncommitted change to the settings for a single item through the Interoperability Portal will block other users from editing that item while allowing changes to other items in the production. + +## Enabling production decomposition +The feature may be enabled by checking the "Decompose Productions" box in the Git Settings page. For deployment of changes to other environments through git to work properly, the value of this setting must match on all namespaces connected to this repository. To assist, settings are automatically exported into a `embedded-git-config.json` file at the root of the repository that may be committed and imported into other environments. + +If there are existing productions in the namespace, they should be migrated to the new decomposed format by running `do ##class(SourceControl.Git.API).BaselineProductions()`. You may then use the Git Web UI to view, commit, and push the corresponding changes. This method should be run in a single namespace and then deployed to other namespaces through normal Embedded Git deployment mechanisms. + +## Editing productions in the IDE +There are a couple of limitations related to editing a production class directly in an integrated development environment (Studio or VS Code). +- Any elements of the class definition other than the production definition (for example, methods, parameters, or a custom superclass) are not source controlled if production decomposition is enabled. A recommended workaround is to move these items to a separate utility class. +- The hooks in the IDE are not able to detect which specific production items are being edited. As a result, if any item has an uncommitted change from a different user, you will be blocked from editing the production in the IDE entirely. +As a result of these limitations, editing decomposed productions in the IDE is prohibited by default. To enable it, enable the "Decomposed Productions Allow IDE" setting on the settings page. + +## Known Limitations +- Any custom methods, parameters, etc. in the production class will not be source controlled if Production Decomposition is enabled. A recommended workaround is to move these items to a separate utility class. +- Production Decomposition is not supported for deployment of changes to productions using the InterSystems Package Manager. diff --git a/docs/testing.md b/docs/testing.md index afe4ae09..039aa7f0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -18,4 +18,18 @@ The following is a testing plan that should be followed prior to release of a ne - Add, edit, and delete items through Studio / VS Code. Use the Sync option. All changes should be committed and pushed to the remote. - Add, edit, and delete items on the remote. Add, edit, and delete unrelated items through Studio/VSCode. All changes should be pulled, committed, and pushed. - Add an item to an interoperability production and sync. Check out a new feature branch. The item should no longer exist in the production. Set the previous branch as the remote merge branch. Sync. The new item should exist in the production. - - Add an item to a production and sync. Check out a new feature branch. The item should no longer exist in the production. Set the previous branch as the remote merge branch. Add a new item to the production. Sync. The production should now have both new items, and the source control output should show it automatically resolved a conflict. \ No newline at end of file + - Add an item to a production and sync. Check out a new feature branch. The item should no longer exist in the production. Set the previous branch as the remote merge branch. Add a new item to the production. Sync. The production should now have both new items, and the source control output should show it automatically resolved a conflict. + + ## Testing production decomposition + - Enable production decomposition in the git-source-control settings. + - In Basic mode, check out a new branch. Create a new production, add some items, and sync. Confirm a file for production settings and a file for each production item has been added to the /ptd subdirectory and pushed to the remote repository. + - In Advanced mode, create a new user. Log in and modify some items on the production. As the previous user, try to modify items in the production. I should not be able to modify those items modified by the other users. + - Revert some production items through the workspace view in the Web UI. The production should automatically update. + - In Basic mode, test deployment: + - Create a new namespace and enable basic mode and production decomposition. Set the default merge branch to the branch checked out on the other namespace. Sync and confirm that the new production has been created with all expected items. + - On the original namespace, delete and modify some items from the production, then sync. On the second namespace, sync again. The items should be deleted and modified to match. + - Test migration of a production to decomposed format: + - On the initial namespace, disable production decomposition. Create a new production and add a number of items. Sync and confirm it has been pushed to the remote repository. + - On the second namespace, sync and confirm the new production has been created. + - On the initial namespace, turn on production decomposition. From terminal, run `do ##class(SourceControl.Git.API).BaselineProductions()`. Confirm the Web UI includes changes for delete of the old production class and adds for all production items. Commit all items and push the branch. + - On the second namespace, turn on production decomposition. Sync. The production should be reloaded with no changes. \ No newline at end of file diff --git a/test/UnitTest/SourceControl/Git/ProductionDecomposition.cls b/test/UnitTest/SourceControl/Git/ProductionDecomposition.cls new file mode 100644 index 00000000..3ca53fe2 --- /dev/null +++ b/test/UnitTest/SourceControl/Git/ProductionDecomposition.cls @@ -0,0 +1,186 @@ +Include SourceControl.Git + +Class UnitTest.SourceControl.Git.ProductionDecomposition Extends %UnitTest.TestCase +{ + +Parameter ProductionName = "UnitTest.SampleProduction"; + +Property SourceControlGlobal [ MultiDimensional ]; + +Property InitialExtension As %String [ InitialExpression = {##class(%Studio.SourceControl.Interface).SourceControlClassGet()} ]; + +Method TestDecomposeExistingProduction() +{ + do $System.OBJ.Delete(..#ProductionName) + set settings = ##class(SourceControl.Git.Settings).%New() + set settings.decomposeProductions = 0 + $$$ThrowOnError(settings.%Save()) + $$$ThrowOnError(##class(SourceControl.Git.Production).CreateProduction(..#ProductionName)) + do ..ReplaceProductionDefinition("ProductionDefinition1") + $$$ThrowOnError(##class(SourceControl.Git.Utils).AddToSourceControl(..#ProductionName_".cls")) + do $$$AssertTrue(##class(SourceControl.Git.Utils).IsInSourceControl(..#ProductionName_".cls")) + $$$ThrowOnError(##class(SourceControl.Git.Utils).Commit(..#ProductionName_".cls")) + + set settings.decomposeProductions = 1 + $$$ThrowOnError(settings.%Save()) + do ##class(SourceControl.Git.API).BaselineProductions() + do ##class(SourceControl.Git.Utils).RunGitCommand("add",,,".") + do ##class(SourceControl.Git.Utils).RunGitCommand("commit",,,"-m","decomposing production from unit test") + do $$$AssertTrue(##class(SourceControl.Git.Utils).IsInSourceControl("UnitTest.SampleProduction||ProductionSettings-UnitTest.SampleProduction.PTD")) + do $$$AssertTrue(##class(SourceControl.Git.Utils).IsInSourceControl("UnitTest.SampleProduction||Settings-a|Ens.Activity.Operation.Local.PTD")) + do $$$AssertNotTrue(##class(SourceControl.Git.Utils).IsInSourceControl(..#ProductionName_".cls")) + + set settings.decomposeProductions = 0 + $$$ThrowOnError(settings.%Save()) + do ##class(SourceControl.Git.API).BaselineProductions() + do ##class(SourceControl.Git.Utils).RunGitCommand("add",,,".") + do ##class(SourceControl.Git.Utils).RunGitCommand("commit",,,"-m","recomposing production from unit test") + do $$$AssertNotTrue(##class(SourceControl.Git.Utils).IsInSourceControl("UnitTest.SampleProduction||ProductionSettings-UnitTest.SampleProduction.PTD")) + do $$$AssertNotTrue(##class(SourceControl.Git.Utils).IsInSourceControl("UnitTest.SampleProduction||Settings-a|Ens.Activity.Operation.Local.PTD")) + do $$$AssertTrue(##class(SourceControl.Git.Utils).IsInSourceControl(..#ProductionName_".cls")) +} + +Method TestEditProduction() +{ + new %session, %request, %SourceControl + set %session = ##class(%CSP.Session).%New("dummysession") + set %request = ##class(%CSP.Request).%New() + set %request.Data("pageclass",1) = "EnsPortal.dummy" + do $System.OBJ.Delete(..#ProductionName) + $$$ThrowOnError(##class(SourceControl.Git.Utils).NewBranch("branch1")) + $$$ThrowOnError(##class(SourceControl.Git.Production).CreateProduction(..#ProductionName)) + do ##class(%Studio.SourceControl.Interface).SourceControlCreate() + $$$ThrowOnError(##class(SourceControl.Git.Utils).AddToSourceControl(..#ProductionName_".cls")) + + do $$$LogMessage("with production decomposition enabled, the production class should not be in source control.") + do $$$AssertNotTrue(##class(SourceControl.Git.Utils).IsInSourceControl(..#ProductionName_".cls")) + do $$$LogMessage("initial creation of a production should export production settings and add to source control.") + $$$ThrowOnError(%SourceControl.OnBeforeSave(..#ProductionName_".cls")) + $$$ThrowOnError(%SourceControl.OnAfterSave(..#ProductionName_".cls")) + do $$$AssertTrue(##class(SourceControl.Git.Utils).IsInSourceControl("UnitTest.SampleProduction||ProductionSettings-UnitTest.SampleProduction.PTD")) + + do $$$LogMessage("adding a production item should add it to source control") + $$$ThrowOnError(%SourceControl.OnBeforeSave(..#ProductionName_".cls")) + do ..ReplaceProductionDefinition("ProductionDefinition1") + $$$ThrowOnError(%SourceControl.OnAfterSave(..#ProductionName_".cls")) + do $$$AssertTrue(##class(SourceControl.Git.Utils).IsInSourceControl("UnitTest.SampleProduction||Settings-a|Ens.Activity.Operation.Local.PTD")) + + do $$$LogMessage("committing changes to production settings") + do ##class(SourceControl.Git.Utils).RunGitCommand("add",,,".") + do ##class(SourceControl.Git.Utils).Commit("UnitTest.SampleProduction||ProductionSettings-UnitTest.SampleProduction.PTD") + do ##class(SourceControl.Git.Utils).Commit("UnitTest.SampleProduction||Settings-a|Ens.Activity.Operation.Local.PTD") + + do $$$LogMessage("switching to a new branch") + $$$ThrowOnError(##class(SourceControl.Git.Utils).NewBranch("branch2")) + + do $$$LogMessage("adding a new item and modifying an existing item") + $$$ThrowOnError(%SourceControl.OnBeforeSave(..#ProductionName_".cls")) + do ..ReplaceProductionDefinition("ProductionDefinition2") + $$$ThrowOnError(%SourceControl.OnAfterSave(..#ProductionName_".cls")) + set production = ##class(Ens.Config.Production).%OpenId(..#ProductionName) + set production.Items.GetAt(1).Settings.GetAt(1).Value = 71 + $$$ThrowOnError(%SourceControl.OnBeforeSave(..#ProductionName_".cls")) + do ..ReplaceProductionDefinition("ProductionDefinition3") + $$$ThrowOnError(%SourceControl.OnAfterSave(..#ProductionName_".cls")) + do $$$AssertTrue(##class(SourceControl.Git.Utils).IsInSourceControl("UnitTest.SampleProduction||Settings-b|Ens.Activity.Operation.Local.PTD")) + do ##class(SourceControl.Git.Utils).RunGitCommand("add",,,".") + do ##class(SourceControl.Git.Utils).Commit("UnitTest.SampleProduction||Settings-a|Ens.Activity.Operation.Local.PTD") + do ##class(SourceControl.Git.Utils).Commit("UnitTest.SampleProduction||Settings-b|Ens.Activity.Operation.Local.PTD") + $$$ThrowOnError(production.%Reload()) + do $$$AssertEquals(production.Items.Count(), 2) + do $$$AssertEquals(production.Items.GetAt(1).Settings.GetAt(1).Name, "RecordStatsInterval") + do $$$AssertEquals(production.Items.GetAt(1).Settings.GetAt(1).Value, 71) + + do $$$LogMessage("switching back to the original branch should modify and delete items") + $$$ThrowOnError(##class(SourceControl.Git.Utils).SwitchBranch("branch1")) + $$$ThrowOnError(production.%Reload()) + do $$$AssertEquals(production.Items.Count(), 1) + do $$$AssertEquals(production.Items.GetAt(1).Settings.GetAt(1).Name, "RecordStatsInterval") + do $$$AssertEquals(production.Items.GetAt(1).Settings.GetAt(1).Value, 61) +} + +ClassMethod ReplaceProductionDefinition(pXDataName) +{ + new %SourceControl + set productionClass = ##class(%Dictionary.ClassDefinition).%OpenId(..#ProductionName) + do productionClass.XDatas.Clear() + set productionXData = ##class(%Dictionary.XDataDefinition).%New() + set xdata = ##class(%Dictionary.XDataDefinition).IDKEYOpen($classname(),pXDataName,,.st) + $$$ThrowOnError(st) + set productionXData = xdata.%ConstructClone(1) + set productionXData.Name = "ProductionDefinition" + set st = productionClass.XDatas.Insert(productionXData) + $$$ThrowOnError(st) + set st = productionClass.%Save() + $$$ThrowOnError(st) + set st = $System.OBJ.Compile(..#ProductionName) + $$$ThrowOnError(st) + set st = ##class(Ens.Production).Update() + $$$ThrowOnError(st) +} + +XData ProductionDefinition1 +{ + + + 61 + + +} + +/// adds item b +XData ProductionDefinition2 +{ + + + 61 + + + + +} + +/// modifies a setting for item a +XData ProductionDefinition3 +{ + + + 71 + + + + +} + +Method OnBeforeAllTests() As %Status +{ + merge ..SourceControlGlobal = ^SYS("SourceControl") + return $$$OK +} + +Method OnBeforeOneTest() As %Status +{ + kill ^SYS("SourceControl") + do ##class(%Studio.SourceControl.Interface).SourceControlClassSet("SourceControl.Git.Extension") + set settings = ##class(SourceControl.Git.Settings).%New() + set settings.decomposeProductions = 1 + Set settings.namespaceTemp = ##class(%Library.File).TempFilename()_"dir" + Set settings.Mappings("PTD","*")="ptd/" + $$$ThrowOnError(settings.%Save()) + // using work queue manager ensures proper OS user context/file ownership + set workMgr = $System.WorkMgr.%New("") + $$$ThrowOnError(workMgr.Queue("##class(SourceControl.Git.Utils).Init")) + $$$ThrowOnError(workMgr.WaitForComplete()) + quit $$$OK +} + +Method %OnClose() As %Status +{ + do ##class(%Studio.SourceControl.Interface).SourceControlClassSet(..InitialExtension) + kill ^SYS("SourceControl") + merge ^SYS("SourceControl") = ..SourceControlGlobal + do $System.OBJ.Delete(..#ProductionName) + quit $$$OK +} + +} diff --git a/test/UnitTest/SourceControl/Git/Settings.cls b/test/UnitTest/SourceControl/Git/Settings.cls new file mode 100644 index 00000000..d2ebb34a --- /dev/null +++ b/test/UnitTest/SourceControl/Git/Settings.cls @@ -0,0 +1,128 @@ +Class UnitTest.SourceControl.Git.Settings Extends %UnitTest.TestCase +{ + +Property SourceControlGlobal [ MultiDimensional ]; + +Property InitialExtension As %String [ InitialExpression = {##class(%Studio.SourceControl.Interface).SourceControlClassGet()} ]; + +Method SampleSettingsJSON() +{ + return { + "pullEventClass": "pull event class", + "percentClassReplace": "x", + "decomposeProductions": true, + "Mappings": { + "TUV": { + "*": { + "directory": "tuv/" + }, + "UnitTest": { + "directory": "tuv2/", + "noFolders": true + } + }, + "XYZ": { + "*": { + "directory": "xyz/" + } + } + } + } +} + +Method TestJSONImportExport() +{ + set settingsDynObj = ..SampleSettingsJSON() + set settings = ##class(SourceControl.Git.Settings).%New() + set settings.decomposeProductions = "" + set settings.percentClassReplace = "" + set settings.pullEventClass = "" + do settings.ImportDynamicObject(settingsDynObj) + do $$$AssertEquals(settings.decomposeProductions, 1) + do $$$AssertEquals(settings.percentClassReplace, "x") + do $$$AssertEquals(settings.pullEventClass, "pull event class") + do $$$AssertEquals($get(settings.Mappings("TUV","*")),"tuv/") + do $$$AssertEquals($get(settings.Mappings("TUV","UnitTest")),"tuv2/") + do $$$AssertTrue($get(settings.Mappings("TUV","UnitTest","NoFolders"))) + do $$$AssertEquals($get(settings.Mappings("XYZ","*")),"xyz/") + + $$$ThrowOnError(settings.%Save()) + set document = ##class(%RoutineMgr).%OpenId(##class(SourceControl.Git.Settings.Document).#INTERNALNAME) + set settingsDynObj = ##class(%DynamicObject).%FromJSON(document.Code) + do $$$AssertEquals(settingsDynObj.decomposeProductions, 1) + do $$$AssertEquals(settingsDynObj.percentClassReplace, "x") + do $$$AssertEquals(settingsDynObj.pullEventClass, "pull event class") + do $$$AssertEquals(settingsDynObj.Mappings."TUV"."*".directory,"tuv/") + do $$$AssertEquals(settingsDynObj.Mappings."TUV"."UnitTest".directory,"tuv2/") + do $$$AssertTrue(settingsDynObj.Mappings."TUV"."UnitTest".noFolders) + do $$$AssertEquals(settingsDynObj.Mappings."XYZ"."*".directory,"xyz/") +} + +Method TestSaveAndImportSettings() +{ + // save settings + set settings = ##class(SourceControl.Git.Settings).%New() + set settings.Mappings("CLS","Foo") = "foo/" + set settings.pullEventClass = "SourceControl.Git.PullEventHandler.Default" + set settings.percentClassReplace = "_" + set settings.decomposeProductions = 1 + $$$ThrowOnError(settings.SaveWithSourceControl()) + do $$$AssertStatusOK(##class(SourceControl.Git.Utils).AddToSourceControl("embedded-git-config.GSC")) + // settings file should be in source control + do $$$AssertTrue(##class(SourceControl.Git.Utils).IsInSourceControl("embedded-git-config.GSC")) + do $$$AssertEquals($replace(##class(SourceControl.Git.Utils).ExternalName("embedded-git-config.GSC"),"\","/"),"embedded-git-config.json") + // commit settings + do $$$AssertStatusOK(##class(SourceControl.Git.Utils).Commit("embedded-git-config.GSC")) + // settings should be in the global + do $$$AssertEquals(^SYS("SourceControl","Git","settings","mappings","CLS","Foo"),"foo/") + do $$$AssertEquals(^SYS("SourceControl","Git","settings","pullEventClass"),"SourceControl.Git.PullEventHandler.Default") + do $$$AssertEquals(^SYS("SourceControl","Git","settings","percentClassReplace"),"_") + do $$$AssertEquals(^SYS("SourceControl","Git","settings","decomposeProductions"),"1") + // change and save settings + set settings.Mappings("CLS","Foo") = "foo2/" + set settings.pullEventClass = "SourceControl.Git.PullEventHandler.IncrementalLoad" + set settings.percentClassReplace = "x" + set settings.decomposeProductions = 0 + $$$ThrowOnError(settings.SaveWithSourceControl()) + // new setting should be in the global + do $$$AssertEquals(^SYS("SourceControl","Git","settings","mappings","CLS","Foo"),"foo2/") + do $$$AssertEquals(^SYS("SourceControl","Git","settings","pullEventClass"),"SourceControl.Git.PullEventHandler.IncrementalLoad") + do $$$AssertEquals(^SYS("SourceControl","Git","settings","percentClassReplace"),"x") + do $$$AssertEquals(^SYS("SourceControl","Git","settings","decomposeProductions"),"0") + // revert change to settings + do $$$AssertStatusOK(##class(SourceControl.Git.Utils).Revert("embedded-git-config.GSC")) + // old setting should be in the global + do $$$AssertEquals(^SYS("SourceControl","Git","settings","mappings","CLS","Foo"),"foo/") + do $$$AssertEquals(^SYS("SourceControl","Git","settings","pullEventClass"),"SourceControl.Git.PullEventHandler.Default") + do $$$AssertEquals(^SYS("SourceControl","Git","settings","percentClassReplace"),"_") + do $$$AssertEquals(^SYS("SourceControl","Git","settings","decomposeProductions"),"1") +} + +Method OnBeforeAllTests() As %Status +{ + merge ..SourceControlGlobal = ^SYS("SourceControl") + return $$$OK +} + +Method OnBeforeOneTest() As %Status +{ + kill ^SYS("SourceControl") + do ##class(%Studio.SourceControl.Interface).SourceControlClassSet("SourceControl.Git.Extension") + set settings = ##class(SourceControl.Git.Settings).%New() + set settings.namespaceTemp = ##class(%Library.File).TempFilename()_"dir" + $$$ThrowOnError(settings.%Save()) + set workMgr = $System.WorkMgr.%New("") + $$$ThrowOnError(workMgr.Queue("##class(SourceControl.Git.Utils).Init")) + $$$ThrowOnError(workMgr.WaitForComplete()) + quit $$$OK +} + +Method %OnClose() As %Status +{ + do ##class(%Studio.SourceControl.Interface).SourceControlClassSet(..InitialExtension) + kill ^SYS("SourceControl") + merge ^SYS("SourceControl") = ..SourceControlGlobal + quit $$$OK +} + +} diff --git a/test/UnitTest/SourceControl/Git/Util/Production.cls b/test/UnitTest/SourceControl/Git/Util/Production.cls new file mode 100644 index 00000000..2ccbf11f --- /dev/null +++ b/test/UnitTest/SourceControl/Git/Util/Production.cls @@ -0,0 +1,34 @@ +Include SourceControl.Git + +Class UnitTest.SourceControl.Git.Util.Production Extends %UnitTest.TestCase +{ + +Property Mappings [ MultiDimensional ]; + +Method TestItemIsPTD() +{ + do $$$AssertNotTrue(##class(SourceControl.Git.Util.Production).ItemIsPTD("cls/test.xml")) + do $$$AssertNotTrue(##class(SourceControl.Git.Util.Production).ItemIsPTD("ptd/test.md")) + do $$$AssertNotTrue(##class(SourceControl.Git.Util.Production).ItemIsPTD("")) + do $$$AssertTrue(##class(SourceControl.Git.Util.Production).ItemIsPTD("ptd/test.xml")) + do $$$AssertTrue(##class(SourceControl.Git.Util.Production).ItemIsPTD("ptd2/test.xml")) + do $$$AssertTrue(##class(SourceControl.Git.Util.Production).ItemIsPTD("ptd2\test.xml")) +} + +Method OnBeforeAllTests() As %Status +{ + merge ..Mappings = @##class(SourceControl.Git.Utils).MappingsNode() + kill @##class(SourceControl.Git.Utils).MappingsNode() + set $$$SourceMapping("PTD", "*") = "ptd/" + set $$$SourceMapping("PTD", "Some.Production") = "ptd2/" + quit $$$OK +} + +Method %OnClose() As %Status +{ + kill @##class(SourceControl.Git.Utils).MappingsNode() + merge @##class(SourceControl.Git.Utils).MappingsNode() = ..Mappings + quit $$$OK +} + +}