From af8032791229914da2044584fbe254ed5f07752f Mon Sep 17 00:00:00 2001 From: Viacheslav Azarov Date: Wed, 24 Dec 2025 13:15:06 +0100 Subject: [PATCH 1/3] fix .github/docs folders without CODEOWNERS file --- lua/gh-co/fs.lua | 80 +++++++++++++++++------------------------------- 1 file changed, 28 insertions(+), 52 deletions(-) diff --git a/lua/gh-co/fs.lua b/lua/gh-co/fs.lua index 6676f6c..f8cdd6f 100644 --- a/lua/gh-co/fs.lua +++ b/lua/gh-co/fs.lua @@ -2,16 +2,14 @@ local FS = {} FS.cachedCodeownersFilePath = nil -local function hasGithubDirectory(name, kind) - return name == '.github' and kind == 'directory' -end - -local function hasDocsDirectory(name, kind) - return name == 'docs' and kind == 'directory' -end - -local function hasCodeownersFile(name, kind) - return name == 'CODEOWNERS' and kind == 'file' +local function hasCodeownersFile(path) + local dirContents = vim.fs.dir(path) + for name, kind in dirContents do + if name == 'CODEOWNERS' and kind == 'file' then + return true + end + end + return false end local function getRootDirectoryName(currentPath) @@ -64,54 +62,32 @@ FS.getCodeownersFilePath = function() Not able to detect project root directory. Maybe you should run nvim from the root of the project. ]]) - local githubDirName = nil - local docsDirName = nil + local hasGithubDir = false + local hasDocsDir = false + local hasRootCodeowners = false for name, kind in rootDirContents do - if hasGithubDirectory(name, kind) then - githubDirName = name - end - - if hasDocsDirectory(name, kind) then - docsDirName = name - end - end - - local codeownerDirName = rootDirName .. "/" .. githubDirName or docsDirName - local codeownerDirContents = vim.fs.dir(codeownerDirName) - - assert( - codeownerDirContents, - "Directory " .. codeownerDirName .. " does not seem to exist." - ) - - local codeownerFileNameWithinDefaultFolder = nil - for name, kind in codeownerDirContents do - if hasCodeownersFile(name, kind) then - codeownerFileNameWithinDefaultFolder = name - break + if kind == 'directory' then + if name == '.github' then + hasGithubDir = true + elseif name == 'docs' then + hasDocsDir = true + end + elseif kind == 'file' and name == 'CODEOWNERS' then + hasRootCodeowners = true end end local codeownerFilePath = nil - - -- handles case when project is storing CODEOWNERS file in root directory - if codeownerFileNameWithinDefaultFolder == nil then - for name, kind in vim.fs.dir(rootDirName) do - if hasCodeownersFile(name, kind) then - codeownerFilePath = rootDirName .. "/" .. name - FS.cachedCodeownersFilePath = codeownerFilePath - break - end - end - - return codeownerFilePath - else - codeownerFilePath = codeownerDirName .. "/" .. codeownerFileNameWithinDefaultFolder - - FS.cachedCodeownersFilePath = codeownerFilePath - - return codeownerFilePath + if hasGithubDir and hasCodeownersFile(rootDirName .. '/.github') then + codeownerFilePath = rootDirName .. '/.github/CODEOWNERS' + elseif hasDocsDir and hasCodeownersFile(rootDirName .. '/docs') then + codeownerFilePath = rootDirName .. '/docs/CODEOWNERS' + elseif hasRootCodeowners then + codeownerFilePath = rootDirName .. '/CODEOWNERS' end + + FS.cachedCodeownersFilePath = codeownerFilePath + return codeownerFilePath end FS.openCodeownersFile = function() From 1084f2db9e7885e20e0f91bb50ba8ae9a0d6ebfc Mon Sep 17 00:00:00 2001 From: Viacheslav Azarov Date: Wed, 24 Dec 2025 14:36:32 +0100 Subject: [PATCH 2/3] fix CODEOWNERS in the root dir without both .github or docs folders --- lua/gh-co/fs.lua | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lua/gh-co/fs.lua b/lua/gh-co/fs.lua index f8cdd6f..1ae3849 100644 --- a/lua/gh-co/fs.lua +++ b/lua/gh-co/fs.lua @@ -15,7 +15,7 @@ end local function getRootDirectoryName(currentPath) local dir = vim.fs.dirname( vim.fs.find( - { ".github", "docs" }, + { ".github", "docs", "CODEOWNERS" }, { path = currentPath, upward = true @@ -78,12 +78,14 @@ FS.getCodeownersFilePath = function() end local codeownerFilePath = nil + -- picking the best matching CODEOWNERS file according to priority + -- https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-location if hasGithubDir and hasCodeownersFile(rootDirName .. '/.github') then codeownerFilePath = rootDirName .. '/.github/CODEOWNERS' - elseif hasDocsDir and hasCodeownersFile(rootDirName .. '/docs') then - codeownerFilePath = rootDirName .. '/docs/CODEOWNERS' elseif hasRootCodeowners then codeownerFilePath = rootDirName .. '/CODEOWNERS' + elseif hasDocsDir and hasCodeownersFile(rootDirName .. '/docs') then + codeownerFilePath = rootDirName .. '/docs/CODEOWNERS' end FS.cachedCodeownersFilePath = codeownerFilePath From 8decec08ceb195deda75405eabd7a6aabf062219 Mon Sep 17 00:00:00 2001 From: Viacheslav Azarov Date: Sat, 27 Dec 2025 22:49:23 +0100 Subject: [PATCH 3/3] add test suite for CODEOWNERS file location logic from patch --- lua/gh-co/fs.lua | 1 + lua/gh-co/fs.test.lua | 316 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 lua/gh-co/fs.test.lua diff --git a/lua/gh-co/fs.lua b/lua/gh-co/fs.lua index 1ae3849..41452cf 100644 --- a/lua/gh-co/fs.lua +++ b/lua/gh-co/fs.lua @@ -79,6 +79,7 @@ FS.getCodeownersFilePath = function() local codeownerFilePath = nil -- picking the best matching CODEOWNERS file according to priority + -- luacheck: ignore 631 -- https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-location if hasGithubDir and hasCodeownersFile(rootDirName .. '/.github') then codeownerFilePath = rootDirName .. '/.github/CODEOWNERS' diff --git a/lua/gh-co/fs.test.lua b/lua/gh-co/fs.test.lua new file mode 100644 index 0000000..1ca1659 --- /dev/null +++ b/lua/gh-co/fs.test.lua @@ -0,0 +1,316 @@ +-- Test suite for FS module CODEOWNERS file location logic +-- +-- luacheck: ignore 631 +-- GitHub CODEOWNERS Standard (https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners#codeowners-file-location): +-- Valid locations in priority order: +-- 1. .github/CODEOWNERS (highest priority) +-- 2. CODEOWNERS (root directory) +-- 3. docs/CODEOWNERS (lowest priority) +-- +-- When multiple files exist, GitHub uses the first one found in priority order. + +package.path = "./lua/?.lua;./lua/?/init.lua;" .. package.path + +local lu = require("luaunit") + +-- Helper function to create mock vim.fs based on scenario +local function createMockVimFs(scenario) + return { + dirname = function(path) + if path and #path > 0 then + return scenario.rootPath + end + return nil + end, + + find = function(_names, _opts) + -- Return root path if any of the search targets exist + if scenario.hasGithubDir or scenario.hasDocsDir or scenario.rootHasCodeowners then + return {scenario.rootPath .. "/.github"} -- any child path works + end + return {} + end, + + dir = function(path) + if path == scenario.rootPath then + -- Root directory contents + local items = {} + if scenario.hasGithubDir then + table.insert(items, {".github", "directory"}) + end + if scenario.hasDocsDir then + table.insert(items, {"docs", "directory"}) + end + if scenario.rootHasCodeowners then + table.insert(items, {"CODEOWNERS", "file"}) + end + + local i = 0 + return function() + i = i + 1 + if items[i] then + return items[i][1], items[i][2] + end + return nil + end + elseif path == scenario.rootPath .. "/.github" then + -- .github directory contents + if scenario.githubHasCodeowners then + local called = false + return function() + if not called then + called = true + return "CODEOWNERS", "file" + end + return nil + end + end + return function() return nil end + elseif path == scenario.rootPath .. "/docs" then + -- docs directory contents + if scenario.docsHasCodeowners then + local called = false + return function() + if not called then + called = true + return "CODEOWNERS", "file" + end + return nil + end + end + return function() return nil end + end + + return function() return nil end + end + } +end + +-- Test class +TestFS = {} + +function TestFS:setUp() + self.FS = require("gh-co.fs") + -- Clear cached path + self.FS.cachedCodeownersFilePath = nil + + -- Save original vim global + self.originalVim = _G.vim + + -- Mock minimal vim APIs needed for FS module + _G.vim = { + api = { + nvim_buf_get_name = function() return "/test/file.txt" end + }, + loop = { + cwd = function() return "/test" end + }, + fs = {} -- Will be replaced per test + } +end + +function TestFS:tearDown() + -- Restore original vim global + _G.vim = self.originalVim + + -- Clear module cache to reload fresh for next test + package.loaded["gh-co.fs"] = nil +end + +-- ============================================================================= +-- Priority Tests: Verify GitHub's priority order when multiple files exist +-- ============================================================================= + +-- Priority #1 > #2: When both .github/CODEOWNERS and root/CODEOWNERS exist, +-- .github/CODEOWNERS should be selected (highest priority) +function TestFS:testPriorityGithubOverRoot() -- luacheck: ignore 212 + local scenario = { + rootPath = "/test", + hasGithubDir = true, + hasDocsDir = false, + githubHasCodeowners = true, + docsHasCodeowners = false, + rootHasCodeowners = true + } + + _G.vim.fs = createMockVimFs(scenario) + package.loaded["gh-co.fs"] = nil + local FS = require("gh-co.fs") + + local result = FS.getCodeownersFilePath() + lu.assertEquals(result, "/test/.github/CODEOWNERS") +end + +-- Priority #2 > #3: When both root/CODEOWNERS and docs/CODEOWNERS exist, +-- root/CODEOWNERS should be selected (priority 2 over 3) +function TestFS:testPriorityRootOverDocs() -- luacheck: ignore 212 + local scenario = { + rootPath = "/test", + hasGithubDir = false, + hasDocsDir = true, + githubHasCodeowners = false, + docsHasCodeowners = true, + rootHasCodeowners = true + } + + _G.vim.fs = createMockVimFs(scenario) + package.loaded["gh-co.fs"] = nil + local FS = require("gh-co.fs") + + local result = FS.getCodeownersFilePath() + lu.assertEquals(result, "/test/CODEOWNERS") +end + +-- Priority #1 > #2 > #3: When all three standard locations have CODEOWNERS, +-- .github/CODEOWNERS should be selected (highest priority) +function TestFS:testPriorityAllThreeLocations() -- luacheck: ignore 212 + local scenario = { + rootPath = "/test", + hasGithubDir = true, + hasDocsDir = true, + githubHasCodeowners = true, + docsHasCodeowners = true, + rootHasCodeowners = true + } + + _G.vim.fs = createMockVimFs(scenario) + package.loaded["gh-co.fs"] = nil + local FS = require("gh-co.fs") + + local result = FS.getCodeownersFilePath() + lu.assertEquals(result, "/test/.github/CODEOWNERS") +end + +-- ============================================================================= +-- Standard Location Tests: Single CODEOWNERS file in valid location +-- ============================================================================= + +-- Standard location #2: CODEOWNERS in repository root (most common simple setup) +function TestFS:testRootCodeownersWithoutGithubDocsDir() -- luacheck: ignore 212 + local scenario = { + rootPath = "/test", + hasGithubDir = false, + hasDocsDir = false, + githubHasCodeowners = false, + docsHasCodeowners = false, + rootHasCodeowners = true + } + + _G.vim.fs = createMockVimFs(scenario) + package.loaded["gh-co.fs"] = nil + local FS = require("gh-co.fs") + + local result = FS.getCodeownersFilePath() + lu.assertEquals(result, "/test/CODEOWNERS") +end + +-- ============================================================================= +-- Fallback Scenarios: Directories exist but lack CODEOWNERS files +-- ============================================================================= + +-- Fallback when .github directory exists but has no CODEOWNERS file, +-- should fall back to root/CODEOWNERS (priority #2) +function TestFS:testGithubDirWithoutCodeownersFile() -- luacheck: ignore 212 + local scenario = { + rootPath = "/test", + hasGithubDir = true, + hasDocsDir = false, + githubHasCodeowners = false, + docsHasCodeowners = false, + rootHasCodeowners = true + } + + _G.vim.fs = createMockVimFs(scenario) + package.loaded["gh-co.fs"] = nil + local FS = require("gh-co.fs") + + local result = FS.getCodeownersFilePath() + lu.assertEquals(result, "/test/CODEOWNERS") +end + +-- Fallback when docs directory exists but has no CODEOWNERS file, +-- should fall back to root/CODEOWNERS (priority #2) +function TestFS:testDocsDirWithoutCodeownersFile() -- luacheck: ignore 212 + local scenario = { + rootPath = "/test", + hasGithubDir = false, + hasDocsDir = true, + githubHasCodeowners = false, + docsHasCodeowners = false, + rootHasCodeowners = true + } + + _G.vim.fs = createMockVimFs(scenario) + package.loaded["gh-co.fs"] = nil + local FS = require("gh-co.fs") + + local result = FS.getCodeownersFilePath() + lu.assertEquals(result, "/test/CODEOWNERS") +end + +-- Fallback when both .github and docs directories exist but neither has CODEOWNERS, +-- should fall back to root/CODEOWNERS (priority #2) +function TestFS:testBothDirsWithoutCodeownersFiles() -- luacheck: ignore 212 + local scenario = { + rootPath = "/test", + hasGithubDir = true, + hasDocsDir = true, + githubHasCodeowners = false, + docsHasCodeowners = false, + rootHasCodeowners = true + } + + _G.vim.fs = createMockVimFs(scenario) + package.loaded["gh-co.fs"] = nil + local FS = require("gh-co.fs") + + local result = FS.getCodeownersFilePath() + lu.assertEquals(result, "/test/CODEOWNERS") +end + +-- ============================================================================= +-- Edge Cases: Scenarios where no CODEOWNERS file can be found +-- ============================================================================= + +-- No CODEOWNERS file exists in any of the three standard locations +function TestFS:testNoCodeownersAnywhere() -- luacheck: ignore 212 + local scenario = { + rootPath = "/test", + hasGithubDir = true, + hasDocsDir = true, + githubHasCodeowners = false, + docsHasCodeowners = false, + rootHasCodeowners = false + } + + _G.vim.fs = createMockVimFs(scenario) + package.loaded["gh-co.fs"] = nil + local FS = require("gh-co.fs") + + local result = FS.getCodeownersFilePath() + lu.assertEquals(result, nil) +end + +-- Repository root directory cannot be detected (no .github, docs, or CODEOWNERS markers found) +function TestFS:testNoRootDetection() -- luacheck: ignore 212 + local scenario = { + rootPath = "/test", + hasGithubDir = false, + hasDocsDir = false, + githubHasCodeowners = false, + docsHasCodeowners = false, + rootHasCodeowners = false + } + + -- Override find to return empty array (no root found) + _G.vim.fs = createMockVimFs(scenario) + _G.vim.fs.find = function() return {} end + + package.loaded["gh-co.fs"] = nil + local FS = require("gh-co.fs") + + local result = FS.getCodeownersFilePath() + lu.assertEquals(result, nil) +end + +os.exit(lu.LuaUnit.run())