feat: Add Document and GitRepository entities with repositories and services
- Created Document entity with properties for file management, including filename, originalFilename, mimeType, size, uploadedAt, uploadedBy, relatedEntity, relatedEntityId, and description. - Implemented DocumentRepository for querying documents by related entity and user. - Added GitRepository entity with properties for managing Git repositories, including URL, localPath, branch, provider, accessToken, project, lastSync, name, description, createdAt, and updatedAt. - Implemented GitRepositoryRepository for querying repositories by project. - Developed GitHubService for interacting with GitHub API, including methods for fetching commits, contributions, branches, and repository info. - Developed GitService for local Git repository interactions, including methods for fetching commits, contributions, branches, and repository info. - Developed GiteaService for interacting with Gitea API, including methods for fetching commits, contributions, branches, repository info, and testing connection.
This commit is contained in:
parent
ab4d2bf9f5
commit
3c36fdd9a1
12
.env
12
.env
@ -17,13 +17,13 @@
|
|||||||
###> symfony/framework-bundle ###
|
###> symfony/framework-bundle ###
|
||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
APP_SECRET=83df005f029c92c8e01026218f588371
|
APP_SECRET=83df005f029c92c8e01026218f588371
|
||||||
APP_URL=http://localhost:8000
|
APP_URL=https://mycrm.test
|
||||||
###< symfony/framework-bundle ###
|
###< symfony/framework-bundle ###
|
||||||
|
|
||||||
###> symfony/routing ###
|
###> symfony/routing ###
|
||||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||||
DEFAULT_URI=http://localhost
|
DEFAULT_URI=https://mycrm.test
|
||||||
###< symfony/routing ###
|
###< symfony/routing ###
|
||||||
|
|
||||||
###> doctrine/doctrine-bundle ###
|
###> doctrine/doctrine-bundle ###
|
||||||
@ -58,8 +58,8 @@ CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$'
|
|||||||
###> knpuniversity/oauth2-client-bundle ###
|
###> knpuniversity/oauth2-client-bundle ###
|
||||||
# Pocket-ID OIDC Configuration
|
# Pocket-ID OIDC Configuration
|
||||||
# Get your credentials from your Pocket-ID instance
|
# Get your credentials from your Pocket-ID instance
|
||||||
OAUTH_POCKET_ID_URL=https://your-pocket-id-instance.com
|
OAUTH_POCKET_ID_URL=https://id.osdata-home.de
|
||||||
OAUTH_POCKET_ID_CLIENT_ID=your-client-id
|
OAUTH_POCKET_ID_CLIENT_ID=2e698201-8a79-4598-9b7d-81b57289c340
|
||||||
OAUTH_POCKET_ID_CLIENT_SECRET=your-client-secret
|
OAUTH_POCKET_ID_CLIENT_SECRET=
|
||||||
OAUTH_POCKET_ID_REDIRECT_URI=http://localhost:8000/connect/pocketid/check
|
OAUTH_POCKET_ID_REDIRECT_URI=https://mycrm.test/dashboard
|
||||||
###< knpuniversity/oauth2-client-bundle ###
|
###< knpuniversity/oauth2-client-bundle ###
|
||||||
|
|||||||
@ -23,6 +23,9 @@ import router from './js/router';
|
|||||||
import App from './js/App.vue';
|
import App from './js/App.vue';
|
||||||
import { useAuthStore } from './js/stores/auth';
|
import { useAuthStore } from './js/stores/auth';
|
||||||
|
|
||||||
|
// Global Components
|
||||||
|
import DocumentUpload from './js/components/DocumentUpload.vue';
|
||||||
|
|
||||||
// PrimeVue Components (lazy import as needed in components)
|
// PrimeVue Components (lazy import as needed in components)
|
||||||
import 'primeicons/primeicons.css';
|
import 'primeicons/primeicons.css';
|
||||||
|
|
||||||
@ -169,6 +172,9 @@ app.directive('tooltip', Tooltip);
|
|||||||
app.directive('styleclass', StyleClass);
|
app.directive('styleclass', StyleClass);
|
||||||
app.component('Toast', Toast);
|
app.component('Toast', Toast);
|
||||||
|
|
||||||
|
// Register global components
|
||||||
|
app.component('DocumentUpload', DocumentUpload);
|
||||||
|
|
||||||
app.mount('#app');
|
app.mount('#app');
|
||||||
|
|
||||||
// Apply saved theme colors after mount
|
// Apply saved theme colors after mount
|
||||||
|
|||||||
4
assets/images/logo.svg
Normal file
4
assets/images/logo.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 60" width="200" height="60">
|
||||||
|
<rect width="200" height="60" fill="#1976d2"/>
|
||||||
|
<text x="100" y="35" font-family="Arial, sans-serif" font-size="24" font-weight="bold" fill="white" text-anchor="middle">myCRM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 278 B |
1
assets/images/osdata.svg
Normal file
1
assets/images/osdata.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
50
assets/images/osdata_clear.svg
Normal file
50
assets/images/osdata_clear.svg
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 3520 657" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<path d="M854.186,585.078L992.594,585.078L992.594,569.313L998.75,569.313L998.75,544.704L1015.28,540.088L1018.25,540.088L1018.25,533.366L1025.31,533.366L1025.31,462.428L1039.44,460.697L1039.44,451.322L1055.31,447.866L1172.53,447.866L1172.53,456.657L1184.78,456.657L1184.78,544.463L1194.44,544.463L1194.44,538.7L1210.59,538.7L1210.59,543.166L1279.78,543.166L1279.78,536.535L1307.91,536.535L1307.91,544.607L1325.62,544.607L1338.62,560.038L1338.62,581.226L1347.84,581.226L1361.69,222.9L1327.66,159.078C1327.66,159.078 1335.34,152.928 1349.75,152.928L1349.75,146.201C1349.75,146.201 1333.81,144.372 1333.81,140.719C1333.81,137.066 1342.06,135.722 1342.06,135.722L1343.72,123.897C1343.72,123.897 1340.34,123.228 1340.34,120.538C1340.34,117.841 1366.97,116.109 1366.97,116.109L1368.31,61.707L1372.94,52.962L1372.94,-0L1375.34,-0L1375.34,53.635L1379.84,61.516L1379.84,116.403C1379.84,116.403 1408.5,117.46 1407.53,123.132C1406.56,128.8 1387.06,128.51 1387.06,128.51L1387.06,134.666C1387.06,134.666 1415.41,134.953 1414.06,142.45C1414.06,142.45 1415.91,146.581 1397.25,147.063L1397.25,153.697C1397.25,153.697 1415.72,154.079 1419.75,161.863L1384.19,223.522L1393.41,580.656L1404.38,580.656L1404.38,573.925L1419.75,573.925L1423.38,570.272L1428.69,575.563L1437.44,575.563C1437.44,575.563 1439.97,564.503 1451.38,564.503L1478.31,564.503C1478.31,564.503 1486.09,566.528 1487.84,573.591L1517.59,573.591L1517.59,564.313L1522.59,559.319L1544.97,559.319L1544.97,536.441L1568.72,536.441L1568.72,516.06L1580.94,516.06L1580.94,507.607L1614.38,507.607L1614.38,562.969L1620.81,562.969L1620.81,551.437L1643.97,551.437L1643.97,529.325L1677.25,529.325L1677.25,537.4L1694.53,537.4L1694.53,562.679L1720.31,562.679L1723.66,566.041L1723.66,570.178L1727.41,570.178L1727.41,564.891L1733.09,564.891L1733.09,567.869L1734.81,567.869L1734.81,570.369L1740,570.369L1740,571.619L1754.12,571.619L1754.12,576.994L1770.09,576.994L1770.09,538.075L1783.75,538.075L1783.75,536.06L1785.47,536.06L1785.47,527.309L1808.91,527.309L1808.91,530.576L1813.06,530.576L1813.06,533.078L1816.62,533.078L1816.62,553.547L1813.34,553.547L1813.34,554.991L1829.09,554.991L1829.09,528.46L1837.47,528.46L1837.47,524.903L1867.47,524.903L1867.47,528.366L1871.31,528.366L1871.31,575.272L1985.78,575.272L1985.78,534.419L1990,534.419L1990,522.407L2027.5,522.407L2027.5,572.006L2052.47,572.006L2052.47,574.885L2114.09,574.885L2114.09,515.294C2114.09,515.294 2116.31,506.45 2122.94,506.45C2129.56,506.45 2133.41,510.394 2133.41,510.394L2137.34,510.394L2137.34,507.319L2143.91,507.319L2143.91,521.825L2147.25,521.825L2147.25,571.135L2157.16,571.135L2157.16,498.759L2174.84,498.759L2174.84,200.985L2183.09,200.985L2183.09,217.329L2196,217.329L2196,568.544L2227.91,568.544L2227.91,503.088L2235.69,497.031L2265.09,497.031L2265.09,571.135L2385.72,571.135L2385.72,541.053L2387.66,571.809L2413.03,571.809L2417.66,567.197L2436.88,567.197L2442.06,572.388L2475.12,572.388L2475.12,564.122L2482.81,564.122L2482.81,556.432L2500.5,556.432L2510.69,566.622L2560.09,566.622L2560.09,570.081L2604.69,570.081L2604.69,559.319L2629.69,559.319L2629.69,556.622L2632.38,556.622L2632.38,553.166L2636.12,553.166C2636.12,553.166 2634.19,538.36 2641.97,535.957L2641.97,532.019L2643.91,532.019L2643.91,527.979L2644.75,532.019L2646.59,532.019L2646.59,536.729C2646.59,536.729 2653.44,539.8 2653.44,553.259L2656.78,533.65L2658.62,555.372L2658.62,561.141L2674.47,561.141L2674.47,558.735L2682.25,558.735L2682.25,561.522L2699.66,561.522L2699.66,633.713L854.186,633.713L854.186,585.078Z" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:0.5px;"/>
|
||||||
|
<path d="M2183.75,218.932C2039.62,276.541 1895.34,333.832 1750.88,390.656C1678.66,419.05 1606.38,447.337 1533.97,475.278C1497.75,489.247 1461.53,503.141 1425.19,516.81C1407.03,523.641 1388.88,530.416 1370.62,537.05C1361.5,540.366 1352.38,543.647 1343.22,546.838C1334.06,550.006 1324.88,553.194 1315.56,555.853C1324.88,553.153 1334.03,549.937 1343.19,546.738C1352.34,543.513 1361.44,540.194 1370.56,536.85C1388.75,530.147 1406.91,523.3 1425.06,516.403C1461.31,502.6 1497.5,488.569 1533.66,474.475C1605.97,446.253 1678.12,417.716 1750.25,389.05C1894.5,331.691 2038.53,273.866 2182.47,215.722L2183.75,218.932Z" style="fill-rule:nonzero;"/>
|
||||||
|
<path d="M1315.47,578.76L1531.16,494.476C1603.06,466.347 1674.91,438.129 1746.69,409.788L1961.91,324.357C1997.81,310.204 2033.66,295.894 2069.47,281.522C2087.38,274.338 2105.25,267.101 2123.1,259.716C2132,256.053 2140.94,252.357 2149.78,248.55C2154.22,246.647 2158.63,244.722 2163.03,242.728C2165.22,241.731 2167.41,240.713 2169.56,239.653C2171.72,238.581 2173.91,237.531 2175.91,236.216L2176.1,236.497C2174.1,237.881 2171.94,238.979 2169.78,240.097C2167.66,241.206 2165.47,242.272 2163.31,243.316C2158.94,245.409 2154.56,247.431 2150.19,249.429C2141.38,253.429 2132.56,257.316 2123.72,261.176C2106,268.826 2088.19,276.31 2070.38,283.753C2034.75,298.6 1998.97,313.084 1963.19,327.569C1891.6,356.406 1819.63,384.351 1747.69,412.366C1675.78,440.391 1603.81,468.282 1531.78,496.088L1315.6,579.085L1315.47,578.76Z" style="fill-rule:nonzero;"/>
|
||||||
|
<path d="M2183.75,306.019C2062.66,353.925 1941.47,401.585 1820.12,448.925C1759.47,472.581 1698.78,496.15 1638,519.516C1607.59,531.194 1577.19,542.816 1546.75,554.319C1516.28,565.8 1485.78,577.25 1455.09,588.153C1485.75,577.116 1516.16,565.531 1546.59,553.919C1577,542.272 1607.34,530.519 1637.69,518.707C1698.34,495.078 1758.94,471.238 1819.5,447.313C1940.62,399.441 2061.59,351.241 2182.47,302.8L2183.75,306.019Z" style="fill-rule:nonzero;"/>
|
||||||
|
<path d="M2183.75,325.622C2069.59,370.834 1955.31,415.71 1840.81,460.11C1783.56,482.291 1726.28,504.35 1668.84,526.075C1640.12,536.929 1611.41,547.704 1582.56,558.263C1568.16,563.531 1553.72,568.756 1539.25,573.834C1532,576.382 1524.75,578.885 1517.47,581.313C1510.19,583.725 1502.88,586.116 1495.47,588.153C1502.88,586.079 1510.16,583.656 1517.44,581.203C1524.69,578.747 1531.94,576.209 1539.16,573.634C1553.62,568.484 1568.03,563.197 1582.41,557.854C1611.19,547.163 1639.88,536.256 1668.53,525.269C1725.88,503.272 1783.06,480.941 1840.19,458.497C1954.44,413.569 2068.53,368.156 2182.47,322.41L2183.75,325.622Z" style="fill-rule:nonzero;"/>
|
||||||
|
<path d="M2183.75,397.128C2143.5,413.272 2103.16,429.244 2062.78,445.103C2022.41,460.963 1981.97,476.691 1941.47,492.222C1900.97,507.744 1860.41,523.094 1819.66,537.988C1799.28,545.422 1778.88,552.753 1758.34,559.788C1748.09,563.303 1737.81,566.75 1727.47,570.029C1717.12,573.272 1706.75,576.482 1696.19,578.925C1706.72,576.413 1717.06,573.135 1727.41,569.822C1737.72,566.475 1747.97,562.966 1758.19,559.381C1778.66,552.2 1799.03,544.744 1819.34,537.172C1860,522.019 1900.47,506.394 1940.84,490.607C1981.25,474.809 2021.56,458.81 2061.84,442.691C2102.09,426.566 2142.31,410.322 2182.47,393.929L2183.75,397.128Z" style="fill-rule:nonzero;"/>
|
||||||
|
<path d="M2190.16,409.254L1951.47,499.332L1832.04,544.112L1772.25,566.344C1752.32,573.697 1732.38,581.107 1712.32,588.153C1732.35,580.972 1752.22,573.422 1772.13,565.938L1831.72,543.304L1950.88,497.722L2188.94,406.028L2190.16,409.254Z" style="fill-rule:nonzero;"/>
|
||||||
|
<path d="M2183.69,492.903L2050.19,538.531L1983.5,561.581L1950.22,573.244C1939.13,577.175 1928.03,581.078 1917.07,585.269C1928,580.944 1939.03,576.907 1950.07,572.838L1983.22,560.763L2049.6,536.897L2182.53,489.629L2183.69,492.903Z" style="fill-rule:nonzero;"/>
|
||||||
|
<path d="M2183.75,496.332C2135,515.431 2086.31,534.682 2037.72,554.14C2013.44,563.879 1989.16,573.666 1964.94,583.604C1952.81,588.569 1940.72,593.578 1928.69,598.679C1922.66,601.228 1916.62,603.8 1910.66,606.435C1904.66,609.075 1898.66,611.719 1892.84,614.681C1898.66,611.653 1904.59,608.947 1910.56,606.235C1916.53,603.541 1922.5,600.9 1928.5,598.278C1940.5,593.05 1952.56,587.91 1964.59,582.803C1988.72,572.601 2012.88,562.541 2037.06,552.541C2085.47,532.538 2133.94,512.753 2182.47,493.119L2183.75,496.332Z" style="fill-rule:nonzero;"/>
|
||||||
|
<path d="M2190.31,489.703C2206.68,497.763 2223.15,505.707 2239.65,513.566C2256.18,521.419 2272.72,529.176 2289.34,536.79C2305.97,544.406 2322.62,551.888 2339.43,559.072C2347.84,562.669 2356.28,566.184 2364.78,569.55C2373.28,572.9 2381.81,576.157 2390.53,578.925C2381.78,576.291 2373.18,573.172 2364.62,569.953C2356.06,566.719 2347.56,563.338 2339.09,559.872C2322.18,552.947 2305.37,545.722 2288.62,538.372C2271.87,531.01 2255.22,523.513 2238.56,515.919C2221.93,508.316 2205.34,500.638 2188.81,492.822L2190.31,489.703Z" style="fill-rule:nonzero;"/>
|
||||||
|
<path d="M2183.81,390.331C2255.65,422.456 2327.56,454.431 2399.56,486.172C2435.56,502.035 2471.59,517.844 2507.68,533.494C2525.75,541.312 2543.81,549.094 2561.9,556.772C2570.97,560.609 2580.03,564.422 2589.12,568.172C2598.25,571.885 2607.31,575.66 2616.59,578.925C2607.28,575.729 2598.18,572.016 2589.03,568.369C2579.93,564.687 2570.84,560.944 2561.75,557.175C2543.59,549.622 2525.44,541.978 2507.34,534.288C2471.12,518.9 2434.97,503.356 2398.87,487.756C2326.62,456.541 2254.47,425.097 2182.4,393.497L2183.81,390.331Z" style="fill-rule:nonzero;"/>
|
||||||
|
<path d="M2192.88,403.181C2256.04,432.147 2319.19,461.191 2382.29,490.344L2476.85,534.225L2524.07,556.341C2539.76,563.769 2555.51,571.162 2571.04,578.925C2555.44,571.291 2539.66,564.034 2523.88,556.734L2476.51,535.01L2381.57,491.919C2318.22,463.284 2254.85,434.769 2191.44,406.335L2192.88,403.181Z" style="fill-rule:nonzero;"/>
|
||||||
|
<path d="M2190.28,315.288C2243.37,339.978 2296.59,364.466 2349.84,388.806C2403.12,413.138 2456.47,437.303 2509.94,461.203C2563.44,485.091 2617,508.744 2670.84,531.775C2697.78,543.279 2724.75,554.628 2751.94,565.569C2765.5,571.034 2779.12,576.391 2792.84,581.503C2799.72,584.05 2806.59,586.532 2813.53,588.869C2820.47,591.184 2827.47,593.444 2834.59,595.072C2827.44,593.475 2820.47,591.247 2813.5,588.969C2806.56,586.666 2799.66,584.216 2792.78,581.703C2779.03,576.665 2765.37,571.366 2751.78,565.972C2724.56,555.16 2697.5,543.938 2670.5,532.569C2616.53,509.8 2562.84,486.404 2509.25,462.782C2455.66,439.141 2402.19,415.24 2348.78,391.169C2295.37,367.097 2242.06,342.863 2188.81,318.428L2190.28,315.288Z" style="fill-rule:nonzero;"/>
|
||||||
|
<path d="M2190.28,309.763C2251.09,337.838 2312.12,365.544 2373.25,392.994C2434.37,420.441 2495.62,447.591 2557.09,474.287C2618.56,500.963 2680.22,527.247 2742.31,552.412C2773.37,564.975 2804.53,577.278 2835.96,588.825C2851.72,594.591 2867.5,600.169 2883.47,605.26C2891.44,607.794 2899.47,610.207 2907.59,612.322C2911.62,613.372 2915.72,614.347 2919.81,615.166C2923.91,615.96 2928.06,616.65 2932.25,616.791C2928.06,616.669 2923.91,615.991 2919.81,615.216C2915.69,614.422 2911.62,613.457 2907.56,612.428C2899.44,610.344 2891.41,607.963 2883.41,605.46C2867.44,600.444 2851.59,594.928 2835.84,589.235C2804.34,577.813 2773.09,565.647 2742,553.219C2679.78,528.307 2618,502.291 2556.41,475.878C2494.81,449.441 2433.44,422.55 2372.19,395.372C2310.94,368.179 2249.81,340.725 2188.81,312.904L2190.28,309.763Z" style="fill-rule:nonzero;"/>
|
||||||
|
<path d="M2183.81,229.778C2330.72,294.534 2477.78,358.994 2625,423.016C2698.62,455.025 2772.31,486.916 2846.09,518.528C2883,534.332 2919.9,550.06 2956.91,565.628C2975.4,573.4 2993.93,581.141 3012.5,588.779C3031.06,596.413 3049.62,603.997 3068.37,611.219C3049.62,604.066 3031,596.547 3012.4,588.982C2993.81,581.406 2975.28,573.734 2956.75,566.019C2919.68,550.594 2882.72,534.994 2845.75,519.325C2771.84,487.972 2698.06,456.35 2624.31,424.606C2476.84,361.103 2329.56,297.175 2182.4,232.944L2183.81,229.778Z" style="fill-rule:nonzero;"/>
|
||||||
|
<path d="M2190.25,219.016C2272.03,255.54 2353.88,291.931 2435.78,328.228C2517.66,364.519 2599.59,400.703 2681.59,436.71C2763.62,472.716 2845.72,508.572 2927.97,544.012C2969.09,561.719 3010.28,579.328 3051.59,596.653C3072.22,605.309 3092.91,613.891 3113.69,622.279C3124.06,626.463 3134.47,630.607 3144.94,634.597C3150.16,636.597 3155.41,638.554 3160.69,640.416C3163.31,641.351 3165.97,642.25 3168.62,643.088C3169.97,643.513 3171.31,643.913 3172.66,644.269C3174,644.613 3175.38,644.975 3176.78,645.053C3175.38,644.984 3174,644.625 3172.66,644.285C3171.31,643.925 3169.97,643.532 3168.62,643.119C3165.97,642.287 3163.31,641.391 3160.66,640.466C3155.38,638.622 3150.12,636.675 3144.88,634.7C3134.41,630.731 3124,626.628 3113.59,622.475C3092.81,614.156 3072.09,605.641 3051.41,597.05C3010.06,579.86 2968.81,562.382 2927.62,544.804C2845.25,509.631 2763.06,474.038 2680.91,438.3C2598.78,402.551 2516.72,366.628 2434.72,330.603C2352.72,294.566 2270.75,258.444 2188.84,222.175L2190.25,219.016Z" style="fill-rule:nonzero;"/>
|
||||||
|
<g transform="matrix(1,0,0,1,-340.875,-22.6235)">
|
||||||
|
<path d="M3193.75,627.222L3193.75,656.2L3117.12,655.644L3040.53,656.2L3040.53,627.222L3042.81,627.222L3042.81,630.537L3045.06,630.537L3045.06,627.603L3047.5,627.603L3047.5,630.382L3048.5,630.382L3048.5,623.288L3056.31,623.288L3056.31,627.294L3057.84,627.294L3057.84,629.919L3058.78,629.919L3058.78,612.65L3059.84,612.65L3058.78,608.1C3058.78,608.1 3058.53,605.172 3077.03,602.788C3077.03,602.788 3089.31,584.519 3116.81,579.356L3117.12,575.447L3117.44,579.356C3144.94,584.519 3157.22,602.788 3157.22,602.788C3175.72,605.172 3175.5,608.1 3175.5,608.1L3174.44,612.65L3175.5,612.65L3175.5,629.919L3176.44,629.919L3176.44,627.294L3177.97,627.294L3177.97,623.288L3185.75,623.288L3185.75,630.382L3186.75,630.382L3186.75,627.603L3189.22,627.603L3189.22,630.537L3191.47,630.537L3191.47,627.222L3193.75,627.222Z" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:0.5px;"/>
|
||||||
|
<rect x="3204.41" y="643.247" width="1.781" height="12.819" style="fill:none;stroke:black;stroke-width:0.5px;"/>
|
||||||
|
<path d="M3204.53,655.941L3206.06,655.941L3206.06,643.378L3204.53,643.378L3204.53,655.941ZM3206.34,656.2L3204.28,656.2L3204.28,643.119L3206.34,643.119L3206.34,656.2Z" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:0.5px;"/>
|
||||||
|
<rect x="3217.88" y="643.247" width="1.781" height="12.819" style="fill:none;stroke:black;stroke-width:0.5px;"/>
|
||||||
|
<path d="M3218,655.941L3219.53,655.941L3219.53,643.378L3218,643.378L3218,655.941ZM3219.78,656.2L3217.72,656.2L3217.72,643.119L3219.78,643.119L3219.78,656.2Z" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:0.5px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(0.834484,0,0,1,94.2319,0)">
|
||||||
|
<path d="M910.687,611.25L910.687,633.713L549.875,633.713L549.875,611.25L594.062,611.25L594.062,608.832L597.031,608.832L597.031,597.082L598.5,597.082L598.5,576.794L597.437,576.794L597.437,573.409L599.469,573.409L599.469,567.457L600.438,567.457L600.438,532.919L595.344,527.925L604.219,527.925L604.219,523.338L599.062,521.085L599.062,520.438L603.812,520.438C603.812,520.438 623.375,492.503 667.969,488.397L792.594,488.397C837.188,492.503 856.75,520.438 856.75,520.438L861.5,520.438L861.5,521.085L856.343,523.338L856.343,527.925L865.219,527.925L860.156,532.919L860.156,567.457L861.094,567.457L861.094,573.409L863.125,573.409L863.125,576.794L862.062,576.794L862.062,597.082L863.531,597.082L863.531,608.832L866.5,608.832L866.5,611.25L910.687,611.25Z" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:0.54px;"/>
|
||||||
|
</g>
|
||||||
|
<g transform="matrix(1,0,0,1,-291.663,-22.6235)">
|
||||||
|
<path d="M3517.97,652.95L3519.16,473.844L3511.13,469.638L3511.13,467.062C3511.13,467.062 3475.94,444.41 3453.85,438.581L3453.85,441.047L3438.03,435.447C3438.03,435.447 3436.81,449.341 3427.72,449.566L3427.72,656.2L3518.1,656.2L3517.97,652.95Z" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:0.5px;"/>
|
||||||
|
</g>
|
||||||
|
<path d="M552.687,656.2L552.687,601.71L551.625,601.71L551.625,579.732L549.812,579.732L549.812,563.4L553.094,560.11L529.5,543.556L529.5,532.869L527.813,543.397L504.156,559.582L507.063,562.482L507.063,578.969L503.094,582.935L503.094,621.253L501.406,619.566L501.406,612.85L497.281,612.85L486.062,601.631L476.375,601.631L476.375,591.175L469.969,591.175L469.969,596.062L465.313,591.409L422.344,591.409L414.594,599.15L397.156,589.954L370.75,610.715L364.031,596.672L351.219,596.672L347.844,600.028L245.437,600.028L241.469,586.6L235.219,597.738L224.531,577.288L224.531,568.738L222.688,577.441L218.719,585.532L218.719,539.125L220.875,539.125L220.875,528.9L217.969,507.225L213.219,527.528L207.75,465.863L207.75,437.472L205,441.438L201.781,416.56L201.781,397.478L200.562,417.169L197.969,443.422L195.062,438.084L195.062,465.1L181.469,523.862L178.594,509.212L174.625,532.869L174.625,539.125L177.375,539.125L177.375,584.309L166.531,584.616L161.031,572.25L156.906,579.578L114.156,585.532L97.844,613.466L89.125,603.694L87,607.359L78.156,598.047L74.312,600.947L63.344,586.444L63.344,578.513L57.531,570.113L52.969,574.078L43.031,574.078C43.031,574.078 43.5,560.763 38.469,560.763L37.781,546.341L35.719,560.994C35.719,560.994 30.094,561.909 30.094,572.553L30.094,582.629L21.406,582.629L0,606.213L0,654.829L552.687,656.2Z" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:0.55px;"/>
|
||||||
|
<g transform="matrix(1,0,0,1,-294.626,-22.6235)">
|
||||||
|
<rect x="3148.34" y="645.491" width="282.344" height="10.709" style="fill:none;stroke:black;stroke-width:0.5px;"/>
|
||||||
|
<path d="M3203.59,645.491L3203.59,632.313L3209.25,632.313L3209.25,627.266L3205.25,627.266L3205.25,608.641C3205.25,608.641 3217.87,606.813 3227.68,606.287C3237.53,605.766 3236.84,604.028 3236.84,604.028L3236.84,585.053C3236.84,585.053 3239.28,580.962 3251.62,580.962C3264,580.962 3306.47,579.222 3308.65,587.75L3308.65,605.069C3308.65,605.069 3329.53,607.241 3337.37,604.897C3345.18,602.551 3346.93,600.719 3349.12,599.416C3351.28,598.112 3362.93,593.063 3370.78,593.231C3378.62,593.407 3406.28,591.669 3414.03,595.757L3414.03,645.491L3203.59,645.491Z" style="fill:none;fill-rule:nonzero;stroke:black;stroke-width:0.5px;"/>
|
||||||
|
</g>
|
||||||
|
<path d="M2978.38,648.041L2963.1,596.641L2963.13,596.638L3005.57,593.2L3005.57,593.266L2963.19,596.7L2978.44,648.019L2978.38,648.041Z" style="fill:white;fill-rule:nonzero;"/>
|
||||||
|
<path d="M2915.59,645.972L2915.53,645.972L2915.53,573.962L2923.62,569.157L2943.53,563.994L2956.97,564.003L2965.56,567.953L2965.53,568.013L2956.93,564.062L2943.56,564.062L2923.62,569.212L2915.59,574.013L2915.59,645.972Z" style="fill:white;fill-rule:nonzero;"/>
|
||||||
|
<path d="M2925.16,649.144L2925.16,649.078C2925.19,649.078 2925.19,649.069 2925.19,649.059C2926.44,647.775 2925.16,603.307 2925.13,602.85L2925.13,602.819L2960.6,602.819L2960.6,647.169L2960.53,647.169L2960.53,602.882L2925.19,602.882C2925.28,605.154 2926.5,647.8 2925.25,649.104C2925.22,649.132 2925.19,649.144 2925.16,649.144Z" style="fill:white;fill-rule:nonzero;"/>
|
||||||
|
<path d="M2852.88,649.11L2852.82,649.11L2854.72,616.228L2854.72,616.222L2884.82,603.851L2884.82,603.854L2911.47,614.519L2911.47,614.532L2913.19,649.11L2913.1,649.11L2911.41,614.56L2884.82,603.922L2854.79,616.278L2852.88,649.11Z" style="fill:white;fill-rule:nonzero;"/>
|
||||||
|
<path d="M2933.62,563.172L2933.56,563.172L2933.56,534.166L2915.53,529.538L2915.56,529.475L2933.62,534.119L2933.62,563.172Z" style="fill:white;fill-rule:nonzero;"/>
|
||||||
|
<rect x="2906.41" y="532.082" width="0.063" height="76.443" style="fill:white;"/>
|
||||||
|
<path d="M2702.62,615.788C2701.96,568.919 2699.09,546.047 2699.06,545.828L2699.12,545.816C2699.15,546.038 2702.03,568.916 2702.68,615.784L2702.62,615.788Z" style="fill:white;fill-rule:nonzero;"/>
|
||||||
|
<path d="M2734.16,600.826L2703.57,595.325L2703.6,595.263L2734.16,600.757L2734.16,600.826Z" style="fill:white;fill-rule:nonzero;"/>
|
||||||
|
<path d="M2735.38,613.329L2735.32,613.329L2735.32,572.763L2735.35,572.763L2745.66,570.357L2745.66,570.419L2735.38,572.816L2735.38,613.329Z" style="fill:white;fill-rule:nonzero;"/>
|
||||||
|
<path d="M2702.69,651.125L2700.1,618.803L2700.13,618.797L2736.22,614.675L2745.69,616.744L2745.69,631.506L2759.1,632.541L2759.1,632.572L2758.22,647.691L2758.16,647.684L2759.03,632.597L2745.63,631.572L2745.63,616.791L2736.22,614.741L2700.16,618.853L2702.75,651.119L2702.69,651.125Z" style="fill:white;fill-rule:nonzero;"/>
|
||||||
|
<path d="M2715.94,621.951L2702.82,620.053L2702.82,619.991L2715.94,621.888L2743.94,617.422L2743.94,617.487L2715.94,621.951Z" style="fill:white;fill-rule:nonzero;"/>
|
||||||
|
<rect x="2715.91" y="621.919" width="0.063" height="29.2" style="fill:white;"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 20 KiB |
379
assets/js/components/DocumentUpload.vue
Normal file
379
assets/js/components/DocumentUpload.vue
Normal file
@ -0,0 +1,379 @@
|
|||||||
|
<template>
|
||||||
|
<div class="document-upload">
|
||||||
|
<div class="flex justify-content-between align-items-center mb-3">
|
||||||
|
<h3 v-if="title">{{ title }}</h3>
|
||||||
|
<Button
|
||||||
|
v-if="canUpload"
|
||||||
|
label="Dokument hochladen"
|
||||||
|
icon="pi pi-upload"
|
||||||
|
@click="showUploadDialog = true"
|
||||||
|
severity="success"
|
||||||
|
outlined
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Documents List -->
|
||||||
|
<DataTable
|
||||||
|
:value="documents"
|
||||||
|
:loading="loading"
|
||||||
|
stripedRows
|
||||||
|
size="small"
|
||||||
|
class="p-datatable-sm"
|
||||||
|
>
|
||||||
|
<Column field="originalFilename" header="Dateiname">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<i :class="getFileIcon(data.mimeType)" class="text-xl"></i>
|
||||||
|
<span>{{ data.originalFilename }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="description" header="Beschreibung"></Column>
|
||||||
|
<Column field="size" header="Größe">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatFileSize(data.size) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="uploadedAt" header="Hochgeladen am">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatDate(data.uploadedAt) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="uploadedBy" header="Hochgeladen von">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ data.uploadedBy ? `${data.uploadedBy.firstName} ${data.uploadedBy.lastName}` : 'Unbekannt' }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="Aktionen" style="width: 120px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-download"
|
||||||
|
severity="info"
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
@click="downloadDocument(data)"
|
||||||
|
v-tooltip.top="'Herunterladen'"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="canDelete"
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
@click="confirmDeleteDocument(data)"
|
||||||
|
v-tooltip.top="'Löschen'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<!-- Upload Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="showUploadDialog"
|
||||||
|
:modal="true"
|
||||||
|
header="Dokument hochladen"
|
||||||
|
:style="{ width: '600px' }"
|
||||||
|
>
|
||||||
|
<div class="flex flex-column gap-3">
|
||||||
|
<div class="field">
|
||||||
|
<label for="file">Datei auswählen</label>
|
||||||
|
<FileUpload
|
||||||
|
mode="basic"
|
||||||
|
name="file"
|
||||||
|
accept="image/*,.pdf,.csv,.xls,.xlsx,.doc,.docx"
|
||||||
|
:maxFileSize="10000000"
|
||||||
|
@select="onFileSelect"
|
||||||
|
:auto="false"
|
||||||
|
chooseLabel="Datei wählen"
|
||||||
|
/>
|
||||||
|
<small v-if="selectedFile" class="text-500">
|
||||||
|
{{ selectedFile.name }} ({{ formatFileSize(selectedFile.size) }})
|
||||||
|
</small>
|
||||||
|
<small class="block text-500 mt-2">
|
||||||
|
Erlaubt: Bilder (JPG, PNG, GIF, WebP, SVG), PDF, Excel (XLS, XLSX), CSV, Word (DOC, DOCX) - Max. 10 MB
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="description">Beschreibung (optional)</label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
v-model="uploadDescription"
|
||||||
|
rows="3"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Abbrechen" severity="secondary" @click="closeUploadDialog" />
|
||||||
|
<Button
|
||||||
|
label="Hochladen"
|
||||||
|
icon="pi pi-upload"
|
||||||
|
@click="uploadDocument"
|
||||||
|
:disabled="!selectedFile"
|
||||||
|
:loading="uploading"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="showDeleteDialog"
|
||||||
|
:modal="true"
|
||||||
|
header="Dokument löschen"
|
||||||
|
:style="{ width: '450px' }"
|
||||||
|
>
|
||||||
|
<p>Möchten Sie das Dokument "{{ documentToDelete?.originalFilename }}" wirklich löschen?</p>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Abbrechen" severity="secondary" @click="showDeleteDialog = false" />
|
||||||
|
<Button
|
||||||
|
label="Löschen"
|
||||||
|
severity="danger"
|
||||||
|
@click="deleteDocument"
|
||||||
|
:loading="deleting"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import DataTable from 'primevue/datatable'
|
||||||
|
import Column from 'primevue/column'
|
||||||
|
import Button from 'primevue/button'
|
||||||
|
import Dialog from 'primevue/dialog'
|
||||||
|
import FileUpload from 'primevue/fileupload'
|
||||||
|
import Textarea from 'primevue/textarea'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
entityType: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
entityId: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: 'Dokumente'
|
||||||
|
},
|
||||||
|
canUpload: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
canDelete: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const documents = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const showUploadDialog = ref(false)
|
||||||
|
const showDeleteDialog = ref(false)
|
||||||
|
const selectedFile = ref(null)
|
||||||
|
const uploadDescription = ref('')
|
||||||
|
const uploading = ref(false)
|
||||||
|
const deleting = ref(false)
|
||||||
|
const documentToDelete = ref(null)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDocuments()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadDocuments() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/documents?relatedEntity=${props.entityType}&relatedEntityId=${props.entityId}`
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
console.log('Documents API response:', data)
|
||||||
|
|
||||||
|
// Handle API Platform response
|
||||||
|
const items = data['hydra:member'] || data.member || data
|
||||||
|
|
||||||
|
// Ensure it's an array and not an object
|
||||||
|
if (Array.isArray(items)) {
|
||||||
|
documents.value = items
|
||||||
|
} else {
|
||||||
|
console.error('API returned non-array:', items)
|
||||||
|
documents.value = []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Dokumente:', error)
|
||||||
|
documents.value = []
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Fehler',
|
||||||
|
detail: 'Dokumente konnten nicht geladen werden',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileSelect(event) {
|
||||||
|
selectedFile.value = event.files[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadDocument() {
|
||||||
|
if (!selectedFile.value) return
|
||||||
|
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', selectedFile.value)
|
||||||
|
formData.append('relatedEntity', props.entityType)
|
||||||
|
formData.append('relatedEntityId', props.entityId)
|
||||||
|
if (uploadDescription.value) {
|
||||||
|
formData.append('description', uploadDescription.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/documents/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Upload fehlgeschlagen')
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Erfolg',
|
||||||
|
detail: 'Dokument wurde hochgeladen',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
|
||||||
|
closeUploadDialog()
|
||||||
|
loadDocuments()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Upload:', error)
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Fehler',
|
||||||
|
detail: 'Dokument konnte nicht hochgeladen werden',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUploadDialog() {
|
||||||
|
showUploadDialog.value = false
|
||||||
|
selectedFile.value = null
|
||||||
|
uploadDescription.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadDocument(document) {
|
||||||
|
try {
|
||||||
|
// Open document in new window instead of downloading
|
||||||
|
window.open(`/api/documents/${document.id}/download`, '_blank')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Öffnen:', error)
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Fehler',
|
||||||
|
detail: 'Dokument konnte nicht geöffnet werden',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteDocument(document) {
|
||||||
|
documentToDelete.value = document
|
||||||
|
showDeleteDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDocument() {
|
||||||
|
if (!documentToDelete.value) return
|
||||||
|
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/documents/${documentToDelete.value.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Löschen fehlgeschlagen')
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Erfolg',
|
||||||
|
detail: 'Dokument wurde gelöscht',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
|
||||||
|
showDeleteDialog.value = false
|
||||||
|
documentToDelete.value = null
|
||||||
|
loadDocuments()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Löschen:', error)
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Fehler',
|
||||||
|
detail: 'Dokument konnte nicht gelöscht werden',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileIcon(mimeType) {
|
||||||
|
if (!mimeType) return 'pi pi-file'
|
||||||
|
|
||||||
|
if (mimeType.includes('pdf')) return 'pi pi-file-pdf'
|
||||||
|
if (mimeType.includes('word') || mimeType.includes('document')) return 'pi pi-file-word'
|
||||||
|
if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) return 'pi pi-file-excel'
|
||||||
|
if (mimeType.includes('image')) return 'pi pi-image'
|
||||||
|
if (mimeType.includes('video')) return 'pi pi-video'
|
||||||
|
if (mimeType.includes('audio')) return 'pi pi-volume-up'
|
||||||
|
if (mimeType.includes('zip') || mimeType.includes('compressed')) return 'pi pi-file-import'
|
||||||
|
|
||||||
|
return 'pi pi-file'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.document-upload {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
142
assets/js/components/GitCommitList.vue
Normal file
142
assets/js/components/GitCommitList.vue
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div class="git-commit-list">
|
||||||
|
<DataTable
|
||||||
|
:value="commits"
|
||||||
|
:loading="loading"
|
||||||
|
paginator
|
||||||
|
:rows="20"
|
||||||
|
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||||
|
:total-records="commits.length"
|
||||||
|
responsive-layout="scroll"
|
||||||
|
striped-rows
|
||||||
|
class="p-datatable-sm"
|
||||||
|
>
|
||||||
|
<template #empty>
|
||||||
|
<div class="text-center py-4 text-500">
|
||||||
|
Keine Commits gefunden
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<Column field="shortHash" header="Hash" style="width: 100px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<code class="text-sm">{{ data.shortHash }}</code>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="subject" header="Nachricht">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="font-medium">{{ data.subject }}</div>
|
||||||
|
<div v-if="data.body" class="text-sm text-500 mt-1 whitespace-pre-wrap">
|
||||||
|
{{ data.body }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="author" header="Autor" style="width: 200px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="font-medium">{{ data.author }}</span>
|
||||||
|
<span class="text-sm text-500">{{ data.email }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="date" header="Datum" style="width: 180px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatDate(data.date) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import DataTable from 'primevue/datatable'
|
||||||
|
import Column from 'primevue/column'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
repositoryId: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
branch: {
|
||||||
|
type: String,
|
||||||
|
default: 'main'
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
type: Number,
|
||||||
|
default: 100
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const commits = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadCommits()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => [props.repositoryId, props.branch], () => {
|
||||||
|
loadCommits()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadCommits() {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/api/git-repos/${props.repositoryId}/commits?branch=${props.branch}&limit=${props.limit}`
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json()
|
||||||
|
throw new Error(error.error || 'Fehler beim Laden der Commits')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
commits.value = data.commits || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading commits:', error)
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Fehler',
|
||||||
|
detail: error.message || 'Commits konnten nicht geladen werden',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
commits.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
if (!dateString) return ''
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
loadCommits
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.git-commit-list {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: var(--surface-100);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
291
assets/js/components/GitContributionChart.vue
Normal file
291
assets/js/components/GitContributionChart.vue
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
<template>
|
||||||
|
<div class="git-contribution-chart">
|
||||||
|
<div class="flex justify-between align-items-center mb-4">
|
||||||
|
<h3 class="text-lg font-semibold m-0">Contributions im letzten Jahr</h3>
|
||||||
|
<div v-if="!loading && totalContributions > 0" class="text-500">
|
||||||
|
<i class="pi pi-calendar mr-2"></i>
|
||||||
|
{{ totalContributions }} Commits
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-center py-8">
|
||||||
|
<i class="pi pi-spin pi-spinner text-4xl text-primary"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="p-4 border-round surface-border border-1 text-center">
|
||||||
|
<i class="pi pi-exclamation-triangle text-orange-500 text-3xl mb-2"></i>
|
||||||
|
<p class="text-500">{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="contributionData.length === 0" class="p-4 border-round surface-border border-1 text-center">
|
||||||
|
<i class="pi pi-info-circle text-blue-500 text-3xl mb-2"></i>
|
||||||
|
<p class="text-500">Keine Contributions im letzten Jahr</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<!-- Heatmap-style visualization (similar to GitHub) -->
|
||||||
|
<div class="contribution-heatmap">
|
||||||
|
<div class="heatmap-grid">
|
||||||
|
<div
|
||||||
|
v-for="week in contributionData"
|
||||||
|
:key="week.week"
|
||||||
|
class="heatmap-cell"
|
||||||
|
:class="getIntensityClass(week.count)"
|
||||||
|
:title="`KW ${week.weekNumber} ${week.year}: ${week.count} Commits`"
|
||||||
|
>
|
||||||
|
<span class="cell-count">{{ week.count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legend mt-3 flex gap-2 align-items-center justify-end">
|
||||||
|
<span class="text-sm text-500">Weniger</span>
|
||||||
|
<div class="legend-cell level-0"></div>
|
||||||
|
<div class="legend-cell level-1"></div>
|
||||||
|
<div class="legend-cell level-2"></div>
|
||||||
|
<div class="legend-cell level-3"></div>
|
||||||
|
<div class="legend-cell level-4"></div>
|
||||||
|
<span class="text-sm text-500">Mehr</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bar Chart alternative -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-20rem" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import Chart from 'primevue/chart'
|
||||||
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
repositoryId: {
|
||||||
|
type: Number,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
branch: {
|
||||||
|
type: String,
|
||||||
|
default: 'main'
|
||||||
|
},
|
||||||
|
author: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const contributionData = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref(null)
|
||||||
|
|
||||||
|
const totalContributions = computed(() => {
|
||||||
|
return contributionData.value.reduce((sum, week) => sum + week.count, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
const labels = contributionData.value.map(w => `KW ${w.weekNumber}`)
|
||||||
|
const data = contributionData.value.map(w => w.count)
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Commits pro Woche',
|
||||||
|
data,
|
||||||
|
backgroundColor: '#10b981',
|
||||||
|
borderColor: '#059669',
|
||||||
|
borderWidth: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartOptions = ref({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (context) => {
|
||||||
|
return `${context.parsed.y} Commits`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
display: false
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
maxRotation: 45,
|
||||||
|
minRotation: 45
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
stepSize: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadContributions()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => [props.repositoryId, props.branch, props.author], () => {
|
||||||
|
loadContributions()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadContributions() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url = `/api/git-repos/${props.repositoryId}/contributions?branch=${props.branch}`
|
||||||
|
if (props.author) {
|
||||||
|
url += `&author=${encodeURIComponent(props.author)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.error || 'Fehler beim Laden der Contributions')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
contributionData.value = data.contributions || []
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading contributions:', err)
|
||||||
|
error.value = err.message
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Fehler',
|
||||||
|
detail: err.message || 'Contributions konnten nicht geladen werden',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
contributionData.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIntensityClass(count) {
|
||||||
|
if (count === 0) return 'level-0'
|
||||||
|
if (count <= 2) return 'level-1'
|
||||||
|
if (count <= 5) return 'level-2'
|
||||||
|
if (count <= 10) return 'level-3'
|
||||||
|
return 'level-4'
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
loadContributions
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.git-contribution-chart {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.contribution-heatmap {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(16px, 1fr));
|
||||||
|
gap: 3px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-cell {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-cell:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-count {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.heatmap-cell:hover .cell-count {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-0 {
|
||||||
|
background-color: var(--surface-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-1 {
|
||||||
|
background-color: #9be9a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-2 {
|
||||||
|
background-color: #40c463;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-3 {
|
||||||
|
background-color: #30a14e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-4 {
|
||||||
|
background-color: #216e39;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-cell {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-cell.level-0 {
|
||||||
|
background-color: var(--surface-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-cell.level-1 {
|
||||||
|
background-color: #9be9a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-cell.level-2 {
|
||||||
|
background-color: #40c463;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-cell.level-3 {
|
||||||
|
background-color: #30a14e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-cell.level-4 {
|
||||||
|
background-color: #216e39;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -5,3 +5,9 @@
|
|||||||
<a href="https://www.osdata.org" target="_blank" rel="noopener noreferrer" class="text-primary font-bold hover:underline">2025 © osdata.org</a>
|
<a href="https://www.osdata.org" target="_blank" rel="noopener noreferrer" class="text-primary font-bold hover:underline">2025 © osdata.org</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.logo-icon {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -277,6 +277,89 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Documents Section (only for existing projects) -->
|
||||||
|
<div v-if="editingProject.id" class="mt-4">
|
||||||
|
<div class="font-semibold text-lg mb-3">Dokumente</div>
|
||||||
|
<DocumentUpload
|
||||||
|
entity-type="project"
|
||||||
|
:entity-id="editingProject.id"
|
||||||
|
:can-upload="true"
|
||||||
|
:can-delete="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Git Repositories Section (only for existing projects) -->
|
||||||
|
<div v-if="editingProject.id" class="mt-4">
|
||||||
|
<div class="font-semibold text-lg mb-3">Git Repositories</div>
|
||||||
|
|
||||||
|
<!-- Repository List -->
|
||||||
|
<DataTable
|
||||||
|
v-if="editGitRepositories.length > 0"
|
||||||
|
:value="editGitRepositories"
|
||||||
|
class="mb-3"
|
||||||
|
striped-rows
|
||||||
|
>
|
||||||
|
<Column field="name" header="Name" style="width: 25%">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="font-medium">{{ data.name }}</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="url" header="URL" style="width: 35%">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<a :href="data.url" target="_blank" class="text-primary hover:underline text-sm">
|
||||||
|
{{ data.url }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="provider" header="Provider" style="width: 15%">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.provider || 'local'" severity="info" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="branch" header="Branch" style="width: 15%">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<code class="text-sm">{{ data.branch || 'main' }}</code>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column style="width: 10%">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex gap-2 justify-end">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
@click="editGitRepo(data)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
size="small"
|
||||||
|
text
|
||||||
|
severity="danger"
|
||||||
|
@click="confirmDeleteGitRepo(data)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<div v-else class="p-4 border-round border-1 surface-border text-center text-500 mb-3">
|
||||||
|
<i class="pi pi-github text-3xl mb-2"></i>
|
||||||
|
<p>Keine Git-Repositories verknüpft</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Repository Button -->
|
||||||
|
<Button
|
||||||
|
label="Repository hinzufügen"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
size="small"
|
||||||
|
@click="openAddGitRepoDialog"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@ -285,6 +368,177 @@
|
|||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Git Repository Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="gitRepoDialog"
|
||||||
|
:header="editingGitRepo?.id ? 'Repository bearbeiten' : 'Repository hinzufügen'"
|
||||||
|
:modal="true"
|
||||||
|
:style="{ width: '600px' }"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="gitRepoName">Name *</label>
|
||||||
|
<InputText
|
||||||
|
id="gitRepoName"
|
||||||
|
v-model="editingGitRepo.name"
|
||||||
|
:class="{ 'p-invalid': gitRepoSubmitted && !editingGitRepo.name }"
|
||||||
|
placeholder="z.B. Main Repository"
|
||||||
|
/>
|
||||||
|
<small v-if="gitRepoSubmitted && !editingGitRepo.name" class="p-error">Name ist erforderlich</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="gitRepoUrl">URL *</label>
|
||||||
|
<InputText
|
||||||
|
id="gitRepoUrl"
|
||||||
|
v-model="editingGitRepo.url"
|
||||||
|
:class="{ 'p-invalid': gitRepoSubmitted && !editingGitRepo.url }"
|
||||||
|
placeholder="https://github.com/user/repo"
|
||||||
|
/>
|
||||||
|
<small v-if="gitRepoSubmitted && !editingGitRepo.url" class="p-error">URL ist erforderlich</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="gitRepoProvider">Provider</label>
|
||||||
|
<Select
|
||||||
|
id="gitRepoProvider"
|
||||||
|
v-model="editingGitRepo.provider"
|
||||||
|
:options="gitProviders"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
placeholder="Provider auswählen"
|
||||||
|
/>
|
||||||
|
<small class="text-500">Leer lassen für lokale Repositories</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="gitRepoBranch">Branch</label>
|
||||||
|
<InputText
|
||||||
|
id="gitRepoBranch"
|
||||||
|
v-model="editingGitRepo.branch"
|
||||||
|
placeholder="main"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test Connection Section -->
|
||||||
|
<div v-if="editingGitRepo.url && editingGitRepo.provider" class="p-3 border-round border-1 surface-border">
|
||||||
|
<div class="flex justify-between align-items-center mb-2">
|
||||||
|
<span class="font-medium text-sm">Verbindung testen</span>
|
||||||
|
<Button
|
||||||
|
label="Testen"
|
||||||
|
icon="pi pi-check-circle"
|
||||||
|
size="small"
|
||||||
|
outlined
|
||||||
|
:loading="testingConnection"
|
||||||
|
@click="testRepositoryConnection"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="connectionTestResult" class="mt-2">
|
||||||
|
<div v-if="connectionTestResult.success" class="flex align-items-start gap-2 p-2 border-round surface-ground border-1 border-green-500">
|
||||||
|
<i class="pi pi-check-circle text-green-600 mt-1"></i>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-semibold text-sm text-green-700 dark:text-green-400">Verbindung erfolgreich</div>
|
||||||
|
<div class="text-xs mt-1">{{ connectionTestResult.message }}</div>
|
||||||
|
<div v-if="connectionTestResult.details" class="text-xs mt-1 text-600">
|
||||||
|
<div v-if="connectionTestResult.details.defaultBranch">
|
||||||
|
Standard-Branch: <code>{{ connectionTestResult.details.defaultBranch }}</code>
|
||||||
|
</div>
|
||||||
|
<div v-if="connectionTestResult.details.commitCount">
|
||||||
|
Commits gefunden: {{ connectionTestResult.details.commitCount }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex align-items-start gap-2 p-2 border-round surface-ground border-1 border-red-500">
|
||||||
|
<i class="pi pi-times-circle text-red-600 mt-1"></i>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-semibold text-sm text-red-700 dark:text-red-400">Verbindung fehlgeschlagen</div>
|
||||||
|
<div class="text-xs mt-1">{{ connectionTestResult.message }}</div>
|
||||||
|
<div v-if="connectionTestResult.error" class="text-xs mt-1 text-600 font-mono">
|
||||||
|
{{ connectionTestResult.error }}
|
||||||
|
</div>
|
||||||
|
<div v-if="connectionTestResult.details" class="text-xs mt-2 text-600">
|
||||||
|
<div v-if="connectionTestResult.details.hint" class="font-semibold">
|
||||||
|
💡 {{ connectionTestResult.details.hint }}
|
||||||
|
</div>
|
||||||
|
<div v-if="connectionTestResult.details.owner">
|
||||||
|
Owner: {{ connectionTestResult.details.owner }}
|
||||||
|
</div>
|
||||||
|
<div v-if="connectionTestResult.details.repo">
|
||||||
|
Repo: {{ connectionTestResult.details.repo }}
|
||||||
|
</div>
|
||||||
|
<div v-if="connectionTestResult.details.branch">
|
||||||
|
Branch: {{ connectionTestResult.details.branch }}
|
||||||
|
</div>
|
||||||
|
<div v-if="connectionTestResult.details.defaultBranch">
|
||||||
|
Standard-Branch: {{ connectionTestResult.details.defaultBranch }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="connectionTestResult.trace" class="text-xs mt-2 p-2 surface-100 dark:surface-800 border-round overflow-auto max-h-10rem font-mono">
|
||||||
|
{{ connectionTestResult.trace }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="gitRepoLocalPath">Lokaler Pfad (optional)</label>
|
||||||
|
<InputText
|
||||||
|
id="gitRepoLocalPath"
|
||||||
|
v-model="editingGitRepo.localPath"
|
||||||
|
placeholder="/pfad/zum/repository"
|
||||||
|
/>
|
||||||
|
<small class="text-500">Nur für lokale Git-Repositories erforderlich</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="editingGitRepo.provider && editingGitRepo.provider !== 'local'" class="flex flex-col gap-2">
|
||||||
|
<label for="gitRepoToken">Access Token (optional)</label>
|
||||||
|
<InputText
|
||||||
|
id="gitRepoToken"
|
||||||
|
v-model="editingGitRepo.accessToken"
|
||||||
|
type="password"
|
||||||
|
placeholder="Token für private Repositories"
|
||||||
|
/>
|
||||||
|
<small class="text-500">Für private Repositories oder höhere Rate Limits</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label for="gitRepoDescription">Beschreibung</label>
|
||||||
|
<Textarea
|
||||||
|
id="gitRepoDescription"
|
||||||
|
v-model="editingGitRepo.description"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Optional: Beschreibung des Repositories"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Abbrechen" @click="gitRepoDialog = false" text :disabled="savingGitRepo" />
|
||||||
|
<Button label="Speichern" @click="saveGitRepo" :loading="savingGitRepo" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Delete Git Repo Confirmation Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="deleteGitRepoDialog"
|
||||||
|
header="Repository entfernen"
|
||||||
|
:modal="true"
|
||||||
|
:style="{ width: '450px' }"
|
||||||
|
>
|
||||||
|
<div class="flex align-items-center gap-3">
|
||||||
|
<i class="pi pi-exclamation-triangle text-4xl text-orange-500" />
|
||||||
|
<span>Möchten Sie das Repository <b>{{ gitRepoToDelete?.name }}</b> wirklich entfernen?</span>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Abbrechen" @click="deleteGitRepoDialog = false" text :disabled="deletingGitRepo" />
|
||||||
|
<Button label="Entfernen" @click="deleteGitRepo" severity="danger" :loading="deletingGitRepo" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<!-- Delete Confirmation Dialog -->
|
<!-- Delete Confirmation Dialog -->
|
||||||
<Dialog
|
<Dialog
|
||||||
v-model:visible="deleteDialog"
|
v-model:visible="deleteDialog"
|
||||||
@ -307,96 +561,169 @@
|
|||||||
v-model:visible="viewDialog"
|
v-model:visible="viewDialog"
|
||||||
header="Projekt anzeigen"
|
header="Projekt anzeigen"
|
||||||
:modal="true"
|
:modal="true"
|
||||||
:style="{ width: '900px' }"
|
:style="{ width: '1400px' }"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-4">
|
<TabView>
|
||||||
<!-- Basic Information -->
|
<!-- Project Data Tab -->
|
||||||
<div class="font-semibold text-lg">Grunddaten</div>
|
<TabPanel header="Projektdaten">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<!-- Basic Information -->
|
||||||
<label class="font-medium text-sm text-500">Projektname</label>
|
<div class="p-4 border-round border-1 surface-border">
|
||||||
<div class="text-900">{{ viewingProject.name || '-' }}</div>
|
<div class="font-semibold text-lg mb-3">Grunddaten</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="font-medium text-sm text-500">Projektname</label>
|
||||||
|
<div class="text-900">{{ viewingProject.name || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="font-medium text-sm text-500">Projektnummer</label>
|
||||||
|
<div class="text-900">{{ viewingProject.projectNumber || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 col-span-2">
|
||||||
|
<label class="font-medium text-sm text-500">Beschreibung</label>
|
||||||
|
<div class="text-900 whitespace-pre-wrap">{{ viewingProject.description || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="font-medium text-sm text-500">Kunde</label>
|
||||||
|
<div class="text-900">{{ viewingProject.customer?.companyName || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="font-medium text-sm text-500">Status</label>
|
||||||
|
<Tag v-if="viewingProject.status" :value="viewingProject.status.name" :style="{ backgroundColor: viewingProject.status.color }" />
|
||||||
|
<div v-else class="text-900">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="font-medium text-sm text-500">Typ</label>
|
||||||
|
<Tag :value="viewingProject.isPrivate ? 'Privat' : 'Beruflich'" :severity="viewingProject.isPrivate ? 'info' : 'success'" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project Numbers & Dates -->
|
||||||
|
<div class="p-4 border-round border-1 surface-border">
|
||||||
|
<div class="font-semibold text-lg mb-3">Nummern & Daten</div>
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="font-medium text-sm text-500">Bestellnummer</label>
|
||||||
|
<div class="text-900">{{ viewingProject.orderNumber || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="font-medium text-sm text-500">Bestelldatum</label>
|
||||||
|
<div class="text-900">{{ formatDate(viewingProject.orderDate) || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="font-medium text-sm text-500">Startdatum</label>
|
||||||
|
<div class="text-900">{{ formatDate(viewingProject.startDate) || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="font-medium text-sm text-500">Enddatum</label>
|
||||||
|
<div class="text-900">{{ formatDate(viewingProject.endDate) || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Budget & Hours -->
|
||||||
|
<div class="p-4 border-round border-1 surface-border">
|
||||||
|
<div class="font-semibold text-lg mb-3">Budget & Kontingent</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="font-medium text-sm text-500">Budget</label>
|
||||||
|
<div class="text-900">{{ viewingProject.budget ? formatCurrency(viewingProject.budget) : '-' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="font-medium text-sm text-500">Stundenkontingent</label>
|
||||||
|
<div class="text-900">{{ viewingProject.hourContingent ? `${viewingProject.hourContingent} h` : '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Documents Section -->
|
||||||
|
<div class="p-4 border-round border-1 surface-border">
|
||||||
|
<div class="font-semibold text-lg mb-3">Dokumente</div>
|
||||||
|
<DocumentUpload
|
||||||
|
v-if="viewingProject.id"
|
||||||
|
entity-type="project"
|
||||||
|
:entity-id="viewingProject.id"
|
||||||
|
:can-upload="false"
|
||||||
|
:can-delete="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timestamps -->
|
||||||
|
<div class="p-4 border-round border-1 surface-border">
|
||||||
|
<div class="font-semibold text-lg mb-3">Metadaten</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="font-medium text-sm text-500">Erstellt am</label>
|
||||||
|
<div class="text-900">{{ formatDate(viewingProject.createdAt) || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<label class="font-medium text-sm text-500">Zuletzt geändert</label>
|
||||||
|
<div class="text-900">{{ formatDate(viewingProject.updatedAt) || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- Git Repository Tab -->
|
||||||
|
<TabPanel header="Git Repositories">
|
||||||
|
<div v-if="gitRepositories.length === 0" class="text-center py-8 text-500">
|
||||||
|
<i class="pi pi-github text-6xl mb-3"></i>
|
||||||
|
<p>Keine Git-Repositories verknüpft</p>
|
||||||
|
<p class="text-sm">Füge ein Git-Repository hinzu, um Commits und Contributions zu analysieren.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div v-else class="flex flex-col gap-4">
|
||||||
<label class="font-medium text-sm text-500">Projektnummer</label>
|
<!-- Repository Selector -->
|
||||||
<div class="text-900">{{ viewingProject.projectNumber || '-' }}</div>
|
<div class="flex gap-3 align-items-center">
|
||||||
</div>
|
<label class="font-medium">Repository:</label>
|
||||||
|
<Select
|
||||||
|
v-model="selectedRepository"
|
||||||
|
:options="gitRepositories"
|
||||||
|
option-label="name"
|
||||||
|
placeholder="Repository auswählen"
|
||||||
|
class="flex-1"
|
||||||
|
/>
|
||||||
|
<label v-if="selectedRepository" class="font-medium">Branch:</label>
|
||||||
|
<InputText
|
||||||
|
v-if="selectedRepository"
|
||||||
|
v-model="selectedBranch"
|
||||||
|
placeholder="Branch (z.B. main)"
|
||||||
|
class="w-15rem"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2 col-span-2">
|
<!-- Contribution Chart -->
|
||||||
<label class="font-medium text-sm text-500">Beschreibung</label>
|
<div v-if="selectedRepository" class="p-4 border-round border-1 surface-border">
|
||||||
<div class="text-900 whitespace-pre-wrap">{{ viewingProject.description || '-' }}</div>
|
<GitContributionChart
|
||||||
</div>
|
:repository-id="selectedRepository.id"
|
||||||
|
:branch="selectedBranch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<!-- Commit List -->
|
||||||
<label class="font-medium text-sm text-500">Kunde</label>
|
<div v-if="selectedRepository" class="p-4 border-round border-1 surface-border">
|
||||||
<div class="text-900">{{ viewingProject.customer?.companyName || '-' }}</div>
|
<div class="font-semibold text-lg mb-3">Commit-Historie</div>
|
||||||
|
<GitCommitList
|
||||||
|
:repository-id="selectedRepository.id"
|
||||||
|
:branch="selectedBranch"
|
||||||
|
:limit="50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</TabPanel>
|
||||||
<div class="flex flex-col gap-2">
|
</TabView>
|
||||||
<label class="font-medium text-sm text-500">Status</label>
|
|
||||||
<Tag v-if="viewingProject.status" :value="viewingProject.status.name" :style="{ backgroundColor: viewingProject.status.color }" />
|
|
||||||
<div v-else class="text-900">-</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="font-medium text-sm text-500">Typ</label>
|
|
||||||
<Tag :value="viewingProject.isPrivate ? 'Privat' : 'Beruflich'" :severity="viewingProject.isPrivate ? 'info' : 'success'" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Project Numbers & Dates -->
|
|
||||||
<div class="font-semibold text-lg mt-4">Nummern & Daten</div>
|
|
||||||
<div class="grid grid-cols-3 gap-4">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="font-medium text-sm text-500">Bestellnummer</label>
|
|
||||||
<div class="text-900">{{ viewingProject.orderNumber || '-' }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="font-medium text-sm text-500">Bestelldatum</label>
|
|
||||||
<div class="text-900">{{ formatDate(viewingProject.orderDate) || '-' }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="font-medium text-sm text-500">Startdatum</label>
|
|
||||||
<div class="text-900">{{ formatDate(viewingProject.startDate) || '-' }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="font-medium text-sm text-500">Enddatum</label>
|
|
||||||
<div class="text-900">{{ formatDate(viewingProject.endDate) || '-' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Budget & Hours -->
|
|
||||||
<div class="font-semibold text-lg mt-4">Budget & Kontingent</div>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="font-medium text-sm text-500">Budget</label>
|
|
||||||
<div class="text-900">{{ viewingProject.budget ? formatCurrency(viewingProject.budget) : '-' }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="font-medium text-sm text-500">Stundenkontingent</label>
|
|
||||||
<div class="text-900">{{ viewingProject.hourContingent ? `${viewingProject.hourContingent} h` : '-' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Timestamps -->
|
|
||||||
<div class="font-semibold text-lg mt-4">Metadaten</div>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="font-medium text-sm text-500">Erstellt am</label>
|
|
||||||
<div class="text-900">{{ formatDate(viewingProject.createdAt) || '-' }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<label class="font-medium text-sm text-500">Zuletzt geändert</label>
|
|
||||||
<div class="text-900">{{ formatDate(viewingProject.updatedAt) || '-' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button label="Schließen" @click="viewDialog = false" />
|
<Button label="Schließen" @click="viewDialog = false" />
|
||||||
@ -419,6 +746,13 @@ import DatePicker from 'primevue/datepicker'
|
|||||||
import InputNumber from 'primevue/inputnumber'
|
import InputNumber from 'primevue/inputnumber'
|
||||||
import RadioButton from 'primevue/radiobutton'
|
import RadioButton from 'primevue/radiobutton'
|
||||||
import Tag from 'primevue/tag'
|
import Tag from 'primevue/tag'
|
||||||
|
import TabView from 'primevue/tabview'
|
||||||
|
import TabPanel from 'primevue/tabpanel'
|
||||||
|
import DataTable from 'primevue/datatable'
|
||||||
|
import Column from 'primevue/column'
|
||||||
|
import GitCommitList from '../components/GitCommitList.vue'
|
||||||
|
import GitContributionChart from '../components/GitContributionChart.vue'
|
||||||
|
import DocumentUpload from '../components/DocumentUpload.vue'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const tableRef = ref(null)
|
const tableRef = ref(null)
|
||||||
@ -434,6 +768,27 @@ const deleting = ref(false)
|
|||||||
const typeFilter = ref('all')
|
const typeFilter = ref('all')
|
||||||
const customers = ref([])
|
const customers = ref([])
|
||||||
const statuses = ref([])
|
const statuses = ref([])
|
||||||
|
const gitRepositories = ref([])
|
||||||
|
const selectedRepository = ref(null)
|
||||||
|
const selectedBranch = ref('main')
|
||||||
|
|
||||||
|
// Git Repository Management
|
||||||
|
const editGitRepositories = ref([])
|
||||||
|
const gitRepoDialog = ref(false)
|
||||||
|
const editingGitRepo = ref({})
|
||||||
|
const gitRepoSubmitted = ref(false)
|
||||||
|
const savingGitRepo = ref(false)
|
||||||
|
const deleteGitRepoDialog = ref(false)
|
||||||
|
const gitRepoToDelete = ref(null)
|
||||||
|
const deletingGitRepo = ref(false)
|
||||||
|
const testingConnection = ref(false)
|
||||||
|
const connectionTestResult = ref(null)
|
||||||
|
|
||||||
|
const gitProviders = [
|
||||||
|
{ label: 'GitHub', value: 'github' },
|
||||||
|
{ label: 'Gitea', value: 'gitea' },
|
||||||
|
{ label: 'Lokal', value: 'local' }
|
||||||
|
]
|
||||||
|
|
||||||
// Permission checks (will be replaced with actual permission checks)
|
// Permission checks (will be replaced with actual permission checks)
|
||||||
const canView = computed(() => true)
|
const canView = computed(() => true)
|
||||||
@ -523,9 +878,35 @@ function onDataLoaded(data) {
|
|||||||
console.log('Projects loaded:', data.length)
|
console.log('Projects loaded:', data.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewProject(project) {
|
async function viewProject(project) {
|
||||||
viewingProject.value = { ...project }
|
viewingProject.value = { ...project }
|
||||||
viewDialog.value = true
|
viewDialog.value = true
|
||||||
|
|
||||||
|
// Load Git repositories for this project
|
||||||
|
await loadGitRepositories(project.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGitRepositories(projectId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/git_repositories?project=${projectId}`)
|
||||||
|
if (!response.ok) throw new Error('Fehler beim Laden der Git-Repositories')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
gitRepositories.value = data['hydra:member'] || data.member || data || []
|
||||||
|
|
||||||
|
// Select first repository by default
|
||||||
|
if (gitRepositories.value.length > 0) {
|
||||||
|
selectedRepository.value = gitRepositories.value[0]
|
||||||
|
selectedBranch.value = gitRepositories.value[0].branch || 'main'
|
||||||
|
} else {
|
||||||
|
selectedRepository.value = null
|
||||||
|
selectedBranch.value = 'main'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading git repositories:', error)
|
||||||
|
gitRepositories.value = []
|
||||||
|
selectedRepository.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function editFromView() {
|
function editFromView() {
|
||||||
@ -567,6 +948,241 @@ function editProject(project) {
|
|||||||
}
|
}
|
||||||
submitted.value = false
|
submitted.value = false
|
||||||
projectDialog.value = true
|
projectDialog.value = true
|
||||||
|
|
||||||
|
// Load git repositories for editing if project exists
|
||||||
|
if (project.id) {
|
||||||
|
loadGitRepositoriesForEdit(project.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGitRepositoriesForEdit(projectId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/git_repositories?project=${projectId}`)
|
||||||
|
if (!response.ok) throw new Error('Fehler beim Laden der Git-Repositories')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
editGitRepositories.value = data['hydra:member'] || data.member || data || []
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading git repositories for edit:', error)
|
||||||
|
editGitRepositories.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Git Repository Management Functions
|
||||||
|
function openAddGitRepoDialog() {
|
||||||
|
editingGitRepo.value = {
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
provider: null,
|
||||||
|
branch: 'main',
|
||||||
|
localPath: '',
|
||||||
|
accessToken: '',
|
||||||
|
description: ''
|
||||||
|
}
|
||||||
|
gitRepoSubmitted.value = false
|
||||||
|
connectionTestResult.value = null
|
||||||
|
gitRepoDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function editGitRepo(repo) {
|
||||||
|
editingGitRepo.value = { ...repo }
|
||||||
|
gitRepoSubmitted.value = false
|
||||||
|
connectionTestResult.value = null
|
||||||
|
gitRepoDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testRepositoryConnection() {
|
||||||
|
if (!editingGitRepo.value.url || !editingGitRepo.value.provider) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: 'Hinweis',
|
||||||
|
detail: 'Bitte URL und Provider angeben',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
testingConnection.value = true
|
||||||
|
connectionTestResult.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a temporary repository object for testing
|
||||||
|
const testData = {
|
||||||
|
url: editingGitRepo.value.url,
|
||||||
|
provider: editingGitRepo.value.provider,
|
||||||
|
branch: editingGitRepo.value.branch || 'main',
|
||||||
|
accessToken: editingGitRepo.value.accessToken || null,
|
||||||
|
localPath: editingGitRepo.value.localPath || null
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/git-repos/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify(testData)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
console.log('Test response:', response.status, result)
|
||||||
|
|
||||||
|
if (response.ok && result.success) {
|
||||||
|
connectionTestResult.value = {
|
||||||
|
success: true,
|
||||||
|
message: result.message || 'Repository ist erreichbar und korrekt konfiguriert',
|
||||||
|
details: result.details || {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-fill branch if detected
|
||||||
|
if (result.details?.defaultBranch && !editingGitRepo.value.branch) {
|
||||||
|
editingGitRepo.value.branch = result.details.defaultBranch
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Erfolg',
|
||||||
|
detail: 'Repository-Verbindung erfolgreich getestet',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
connectionTestResult.value = {
|
||||||
|
success: false,
|
||||||
|
message: result.message || 'Verbindung zum Repository fehlgeschlagen',
|
||||||
|
error: result.error || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Fehler',
|
||||||
|
detail: result.message || 'Verbindung fehlgeschlagen',
|
||||||
|
life: 5000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error testing repository connection:', error)
|
||||||
|
connectionTestResult.value = {
|
||||||
|
success: false,
|
||||||
|
message: 'Netzwerkfehler beim Testen der Verbindung',
|
||||||
|
error: error.message
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Fehler',
|
||||||
|
detail: 'Verbindung konnte nicht getestet werden',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
testingConnection.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGitRepo() {
|
||||||
|
gitRepoSubmitted.value = true
|
||||||
|
|
||||||
|
if (!editingGitRepo.value.name || !editingGitRepo.value.url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
savingGitRepo.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const repoData = {
|
||||||
|
name: editingGitRepo.value.name,
|
||||||
|
url: editingGitRepo.value.url,
|
||||||
|
provider: editingGitRepo.value.provider || null,
|
||||||
|
branch: editingGitRepo.value.branch || 'main',
|
||||||
|
localPath: editingGitRepo.value.localPath || null,
|
||||||
|
accessToken: editingGitRepo.value.accessToken || null,
|
||||||
|
description: editingGitRepo.value.description || null,
|
||||||
|
project: `/api/projects/${editingProject.value.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNew = !editingGitRepo.value.id
|
||||||
|
const url = isNew ? '/api/git_repositories' : `/api/git_repositories/${editingGitRepo.value.id}`
|
||||||
|
const method = isNew ? 'POST' : 'PUT'
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(repoData)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.message || 'Fehler beim Speichern des Repositories')
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Erfolg',
|
||||||
|
detail: isNew ? 'Repository hinzugefügt' : 'Repository aktualisiert',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
|
||||||
|
gitRepoDialog.value = false
|
||||||
|
|
||||||
|
// Reload git repositories
|
||||||
|
await loadGitRepositoriesForEdit(editingProject.value.id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving git repository:', error)
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Fehler',
|
||||||
|
detail: error.message || 'Repository konnte nicht gespeichert werden',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
savingGitRepo.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteGitRepo(repo) {
|
||||||
|
gitRepoToDelete.value = repo
|
||||||
|
deleteGitRepoDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGitRepo() {
|
||||||
|
if (!gitRepoToDelete.value) return
|
||||||
|
|
||||||
|
deletingGitRepo.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/git_repositories/${gitRepoToDelete.value.id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Fehler beim Löschen des Repositories')
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: 'Erfolg',
|
||||||
|
detail: 'Repository entfernt',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
|
||||||
|
deleteGitRepoDialog.value = false
|
||||||
|
|
||||||
|
// Reload git repositories
|
||||||
|
await loadGitRepositoriesForEdit(editingProject.value.id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting git repository:', error)
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Fehler',
|
||||||
|
detail: 'Repository konnte nicht entfernt werden',
|
||||||
|
life: 3000
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
deletingGitRepo.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveProject() {
|
async function saveProject() {
|
||||||
|
|||||||
@ -40,3 +40,13 @@ services:
|
|||||||
App\EventListener\MailerLoggerListener:
|
App\EventListener\MailerLoggerListener:
|
||||||
arguments:
|
arguments:
|
||||||
$projectDir: '%kernel.project_dir%'
|
$projectDir: '%kernel.project_dir%'
|
||||||
|
|
||||||
|
# GitHub Service with optional token
|
||||||
|
App\Service\GitHubService:
|
||||||
|
arguments:
|
||||||
|
$githubToken: '%env(string:default::GITHUB_TOKEN)%'
|
||||||
|
|
||||||
|
# Gitea Service with optional token
|
||||||
|
App\Service\GiteaService:
|
||||||
|
arguments:
|
||||||
|
$giteaToken: '%env(string:default::GITEA_TOKEN)%'
|
||||||
|
|||||||
228
docs/GIT_INTEGRATION.md
Normal file
228
docs/GIT_INTEGRATION.md
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
# Git Integration - Dokumentation
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Die Git-Integration unterstützt drei Modi:
|
||||||
|
1. **Remote API** (GitHub/GitLab/Gitea) - **Empfohlen**
|
||||||
|
2. **Lokaler Clone** (Git-Commands auf lokalem Repository)
|
||||||
|
|
||||||
|
## Remote API
|
||||||
|
|
||||||
|
### Unterstützte Provider
|
||||||
|
|
||||||
|
| Provider | Status | API-Dokumentation |
|
||||||
|
|-----------|--------|-------------------|
|
||||||
|
| GitHub | ✅ Implementiert | [GitHub API](https://docs.github.com/en/rest) |
|
||||||
|
| Gitea | ✅ Implementiert | [Gitea API](https://docs.gitea.io/en-us/api-usage/) |
|
||||||
|
| GitLab | 🔜 Geplant | [GitLab API](https://docs.gitlab.com/ee/api/) |
|
||||||
|
| Bitbucket | 🔜 Geplant | [Bitbucket API](https://developer.atlassian.com/cloud/bitbucket/rest/) |
|
||||||
|
|
||||||
|
### Vorteile
|
||||||
|
- ✅ Kein lokaler Clone nötig
|
||||||
|
- ✅ Schneller und skalierbarer
|
||||||
|
- ✅ Automatisch aktuell
|
||||||
|
- ✅ Zusätzliche Daten verfügbar (Stars, Forks, Contributors)
|
||||||
|
|
||||||
|
### Nachteile
|
||||||
|
- ⚠️ Rate Limits (abhängig vom Provider)
|
||||||
|
- ⚠️ Nur für öffentliche Repos ohne Token
|
||||||
|
|
||||||
|
## GitHub Integration
|
||||||
|
|
||||||
|
### Repository hinzufügen (GitHub)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/git_repositories
|
||||||
|
Content-Type: application/ld+json
|
||||||
|
|
||||||
|
{
|
||||||
|
"project": "/api/projects/1",
|
||||||
|
"name": "Mein GitHub Projekt",
|
||||||
|
"url": "https://github.com/username/repository",
|
||||||
|
"branch": "main",
|
||||||
|
"provider": "github",
|
||||||
|
"accessToken": "ghp_XXXXXXXXXXXX" // Optional, für private Repos
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GitHub Personal Access Token erstellen
|
||||||
|
|
||||||
|
1. GitHub → Settings → Developer settings → Personal access tokens → Tokens (classic)
|
||||||
|
2. "Generate new token (classic)"
|
||||||
|
3. Scopes auswählen:
|
||||||
|
- `repo` (für private Repositories)
|
||||||
|
- `public_repo` (nur für öffentliche Repositories)
|
||||||
|
4. Token kopieren und in `accessToken` Feld eintragen
|
||||||
|
|
||||||
|
**Wichtig:** Token niemals im Code committen! In Production: Environment Variable nutzen.
|
||||||
|
|
||||||
|
## Gitea Integration
|
||||||
|
|
||||||
|
### Repository hinzufügen (Gitea)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/git_repositories
|
||||||
|
Content-Type: application/ld+json
|
||||||
|
|
||||||
|
{
|
||||||
|
"project": "/api/projects/1",
|
||||||
|
"name": "Mein Gitea Projekt",
|
||||||
|
"url": "https://gitea.example.com/username/repository",
|
||||||
|
"branch": "main",
|
||||||
|
"provider": "gitea",
|
||||||
|
"accessToken": "your_gitea_token_here" // Optional, für private Repos
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gitea Access Token erstellen
|
||||||
|
|
||||||
|
1. Gitea → Settings → Applications → Generate New Token
|
||||||
|
2. Token Name eingeben
|
||||||
|
3. Scopes auswählen:
|
||||||
|
- `repo` (für Repository-Zugriff)
|
||||||
|
4. Token kopieren und in `accessToken` Feld eintragen
|
||||||
|
|
||||||
|
### Besonderheiten Gitea
|
||||||
|
|
||||||
|
- **Self-hosted**: URL muss vollständige Instanz-URL enthalten (z.B. `https://gitea.example.com/user/repo`)
|
||||||
|
- **API-Limit**: Standardmäßig keine Rate Limits bei self-hosted Gitea
|
||||||
|
- **Contributions**: Werden aus Commit-Historie berechnet (max. 50 Commits pro Anfrage)
|
||||||
|
|
||||||
|
### Unterstützte URL-Formate (Gitea)
|
||||||
|
|
||||||
|
```
|
||||||
|
https://gitea.example.com/username/repo
|
||||||
|
https://gitea.example.com/username/repo.git
|
||||||
|
git@gitea.example.com:username/repo.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Unterstützte Provider
|
||||||
|
|
||||||
|
| Provider | Status | API-Dokumentation |
|
||||||
|
|-----------|--------|-------------------|
|
||||||
|
| GitHub | ✅ Implementiert | [GitHub API](https://docs.github.com/en/rest) |
|
||||||
|
| Gitea | ✅ Implementiert | [Gitea API](https://docs.gitea.io/en-us/api-usage/) |
|
||||||
|
| GitLab | 🔜 Geplant | [GitLab API](https://docs.gitlab.com/ee/api/) |
|
||||||
|
| Bitbucket | 🔜 Geplant | [Bitbucket API](https://developer.atlassian.com/cloud/bitbucket/rest/) |
|
||||||
|
|
||||||
|
## Lokaler Clone
|
||||||
|
|
||||||
|
### Vorteile
|
||||||
|
- ✅ Funktioniert mit jedem Git-Server
|
||||||
|
- ✅ Keine API-Limits
|
||||||
|
- ✅ Volle Git-Historie verfügbar
|
||||||
|
|
||||||
|
### Nachteile
|
||||||
|
- ⚠️ Benötigt Disk Space
|
||||||
|
- ⚠️ Muss regelmäßig aktualisiert werden (git pull)
|
||||||
|
- ⚠️ Git muss auf Server installiert sein
|
||||||
|
|
||||||
|
### Repository hinzufügen (Lokal)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Repository lokal clonen
|
||||||
|
cd /var/www/git-repos
|
||||||
|
git clone https://github.com/username/repository.git
|
||||||
|
|
||||||
|
# 2. Via API registrieren
|
||||||
|
POST /api/git_repositories
|
||||||
|
Content-Type: application/ld+json
|
||||||
|
|
||||||
|
{
|
||||||
|
"project": "/api/projects/1",
|
||||||
|
"name": "Mein lokales Projekt",
|
||||||
|
"url": "https://github.com/username/repository.git",
|
||||||
|
"localPath": "/var/www/git-repos/repository",
|
||||||
|
"branch": "main",
|
||||||
|
"provider": "local"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Repository aktualisieren (Cron-Job)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Crontab: Täglich um 2 Uhr morgens alle Repos pullen
|
||||||
|
0 2 * * * cd /var/www/git-repos/repository && git pull origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Commits abrufen
|
||||||
|
```
|
||||||
|
GET /api/git-repos/{id}/commits?branch=main&limit=100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Contributions abrufen (Wochenstatistik)
|
||||||
|
```
|
||||||
|
GET /api/git-repos/{id}/contributions?branch=main
|
||||||
|
```
|
||||||
|
|
||||||
|
### Branches auflisten
|
||||||
|
```
|
||||||
|
GET /api/git-repos/{id}/branches
|
||||||
|
```
|
||||||
|
|
||||||
|
### Repository validieren
|
||||||
|
```
|
||||||
|
GET /api/git-repos/{id}/validate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment-Variablen (.env)
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Optional: GitHub Token für höhere Rate Limits
|
||||||
|
GITHUB_TOKEN=ghp_XXXXXXXXXXXX
|
||||||
|
|
||||||
|
# Optional: Gitea Token für private Repos
|
||||||
|
GITEA_TOKEN=your_gitea_token_here
|
||||||
|
|
||||||
|
# Optional: GitLab Token
|
||||||
|
GITLAB_TOKEN=glpat-XXXXXXXXXXXX
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rate Limits
|
||||||
|
|
||||||
|
### GitHub
|
||||||
|
- **Ohne Token**: 60 Requests/Stunde
|
||||||
|
- **Mit Token**: 5000 Requests/Stunde
|
||||||
|
- **Enterprise**: 15000 Requests/Stunde
|
||||||
|
|
||||||
|
### Gitea (Self-hosted)
|
||||||
|
- **Standard**: Keine Limits (konfigurierbar durch Admin)
|
||||||
|
- **Empfehlung**: Token verwenden für private Repos
|
||||||
|
|
||||||
|
### Empfehlung
|
||||||
|
Für Production-Umgebungen immer einen Token verwenden und Caching implementieren.
|
||||||
|
|
||||||
|
## Beispiel: Repository über UI hinzufügen
|
||||||
|
|
||||||
|
1. Projekt öffnen
|
||||||
|
2. Tab "Git Repositories" öffnen
|
||||||
|
3. "Repository hinzufügen" klicken
|
||||||
|
4. **Provider auswählen**: `github` oder `gitea`
|
||||||
|
5. **URL eintragen**:
|
||||||
|
- GitHub: `https://github.com/username/repo`
|
||||||
|
- Gitea: `https://gitea.example.com/username/repo`
|
||||||
|
6. **Optional**: Access Token eintragen (für private Repos)
|
||||||
|
7. Speichern
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "Failed to fetch commits: 403 Forbidden"
|
||||||
|
→ Rate Limit erreicht. Token hinzufügen oder warten.
|
||||||
|
|
||||||
|
### "Invalid GitHub URL format"
|
||||||
|
→ URL muss Format haben: `https://github.com/owner/repo` oder `git@github.com:owner/repo.git`
|
||||||
|
|
||||||
|
### "Invalid Gitea URL format"
|
||||||
|
→ URL muss vollständige Gitea-Instanz beinhalten: `https://gitea.example.com/owner/repo`
|
||||||
|
|
||||||
|
### "No data source configured"
|
||||||
|
→ Weder `provider` noch `localPath` gesetzt. Eines von beiden ist erforderlich.
|
||||||
|
|
||||||
|
### Gitea: "Failed to fetch commits"
|
||||||
|
→ Prüfe:
|
||||||
|
- Ist die Gitea-Instanz erreichbar?
|
||||||
|
- Ist der Token gültig?
|
||||||
|
- Hat der Token die nötigen Rechte (`repo` scope)?
|
||||||
|
- Ist das Repository öffentlich oder benötigt einen Token?
|
||||||
33
migrations/Version20251111153432.php
Normal file
33
migrations/Version20251111153432.php
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20251111153432 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE documents (id INT AUTO_INCREMENT NOT NULL, uploaded_by_id INT NOT NULL, filename VARCHAR(255) NOT NULL, original_filename VARCHAR(255) NOT NULL, mime_type VARCHAR(100) NOT NULL, size INT NOT NULL, uploaded_at DATETIME NOT NULL, related_entity VARCHAR(50) NOT NULL, related_entity_id INT NOT NULL, description VARCHAR(255) DEFAULT NULL, INDEX IDX_A2B07288A2B28FE8 (uploaded_by_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||||
|
$this->addSql('ALTER TABLE documents ADD CONSTRAINT FK_A2B07288A2B28FE8 FOREIGN KEY (uploaded_by_id) REFERENCES users (id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE documents DROP FOREIGN KEY FK_A2B07288A2B28FE8');
|
||||||
|
$this->addSql('DROP TABLE documents');
|
||||||
|
}
|
||||||
|
}
|
||||||
33
migrations/Version20251112141740.php
Normal file
33
migrations/Version20251112141740.php
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20251112141740 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE git_repository (id INT AUTO_INCREMENT NOT NULL, project_id INT NOT NULL, url VARCHAR(500) NOT NULL, local_path VARCHAR(500) DEFAULT NULL, branch VARCHAR(100) DEFAULT NULL, last_sync DATETIME DEFAULT NULL, name VARCHAR(255) DEFAULT NULL, description LONGTEXT DEFAULT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', updated_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_C2B3204A166D1F9C (project_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB');
|
||||||
|
$this->addSql('ALTER TABLE git_repository ADD CONSTRAINT FK_C2B3204A166D1F9C FOREIGN KEY (project_id) REFERENCES projects (id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE git_repository DROP FOREIGN KEY FK_C2B3204A166D1F9C');
|
||||||
|
$this->addSql('DROP TABLE git_repository');
|
||||||
|
}
|
||||||
|
}
|
||||||
33
migrations/Version20251112142215.php
Normal file
33
migrations/Version20251112142215.php
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-generated Migration: Please modify to your needs!
|
||||||
|
*/
|
||||||
|
final class Version20251112142215 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add provider and access_token fields to git_repository table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Add new fields to existing git_repository table
|
||||||
|
$this->addSql('ALTER TABLE git_repository ADD provider VARCHAR(50) DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE git_repository ADD access_token VARCHAR(255) DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// Remove the added fields
|
||||||
|
$this->addSql('ALTER TABLE git_repository DROP provider');
|
||||||
|
$this->addSql('ALTER TABLE git_repository DROP access_token');
|
||||||
|
}
|
||||||
|
}
|
||||||
19
package-lock.json
generated
19
package-lock.json
generated
@ -7,6 +7,7 @@
|
|||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@primevue/themes": "^4.4.1",
|
"@primevue/themes": "^4.4.1",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"pinia": "^2.2.0",
|
"pinia": "^2.2.0",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primevue": "^4.3.0",
|
"primevue": "^4.3.0",
|
||||||
@ -2202,6 +2203,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@nuxt/friendly-errors-webpack-plugin": {
|
"node_modules/@nuxt/friendly-errors-webpack-plugin": {
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nuxt/friendly-errors-webpack-plugin/-/friendly-errors-webpack-plugin-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nuxt/friendly-errors-webpack-plugin/-/friendly-errors-webpack-plugin-2.6.0.tgz",
|
||||||
@ -4217,6 +4224,18 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
|
|||||||
@ -21,6 +21,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@primevue/themes": "^4.4.1",
|
"@primevue/themes": "^4.4.1",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"pinia": "^2.2.0",
|
"pinia": "^2.2.0",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primevue": "^4.3.0",
|
"primevue": "^4.3.0",
|
||||||
|
|||||||
19
public/.htaccess
Normal file
19
public/.htaccess
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
# public/.htaccess
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Redirect to HTTPS if not already (optional, if you want HTTPS only)
|
||||||
|
# RewriteCond %{HTTPS} !=on
|
||||||
|
# RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
||||||
|
|
||||||
|
# If the requested filename exists, serve it directly
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -f
|
||||||
|
RewriteRule ^ - [L]
|
||||||
|
|
||||||
|
# If the requested directory exists, serve it directly
|
||||||
|
RewriteCond %{REQUEST_FILENAME} -d
|
||||||
|
RewriteRule ^ - [L]
|
||||||
|
|
||||||
|
# Otherwise, forward to index.php
|
||||||
|
RewriteRule ^ index.php [L]
|
||||||
|
</IfModule>
|
||||||
159
src/Controller/DocumentController.php
Normal file
159
src/Controller/DocumentController.php
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\Document;
|
||||||
|
use App\Entity\User;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\File\Exception\FileException;
|
||||||
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
use Symfony\Component\String\Slugger\SluggerInterface;
|
||||||
|
|
||||||
|
#[Route('/api/documents')]
|
||||||
|
class DocumentController extends AbstractController
|
||||||
|
{
|
||||||
|
private string $uploadsDirectory;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private EntityManagerInterface $entityManager,
|
||||||
|
private SluggerInterface $slugger
|
||||||
|
) {
|
||||||
|
// Get project directory from kernel parameter
|
||||||
|
$this->uploadsDirectory = '%kernel.project_dir%/var/uploads/documents';
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/upload', name: 'api_document_upload', methods: ['POST'])]
|
||||||
|
public function upload(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||||
|
|
||||||
|
// Resolve the actual path
|
||||||
|
$uploadsDirectory = str_replace('%kernel.project_dir%', $this->getParameter('kernel.project_dir'), $this->uploadsDirectory);
|
||||||
|
|
||||||
|
// Erstelle Upload-Verzeichnis falls nicht vorhanden
|
||||||
|
if (!is_dir($uploadsDirectory)) {
|
||||||
|
mkdir($uploadsDirectory, 0755, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var UploadedFile $file */
|
||||||
|
$file = $request->files->get('file');
|
||||||
|
if (!$file) {
|
||||||
|
return $this->json(['error' => 'Keine Datei hochgeladen'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validierung
|
||||||
|
$maxSize = 10 * 1024 * 1024; // 10MB
|
||||||
|
if ($file->getSize() > $maxSize) {
|
||||||
|
return $this->json(['error' => 'Datei ist zu groß (max. 10MB)'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erlaubte Dateitypen
|
||||||
|
$allowedMimeTypes = [
|
||||||
|
// Bilder
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
'image/svg+xml',
|
||||||
|
// PDF
|
||||||
|
'application/pdf',
|
||||||
|
// Excel
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
// CSV
|
||||||
|
'text/csv',
|
||||||
|
'text/plain',
|
||||||
|
// Word
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
];
|
||||||
|
|
||||||
|
$mimeType = $file->getClientMimeType();
|
||||||
|
if (!in_array($mimeType, $allowedMimeTypes)) {
|
||||||
|
return $this->json([
|
||||||
|
'error' => 'Dateityp nicht erlaubt. Erlaubt sind: Bilder (JPG, PNG, GIF, WebP, SVG), PDF, Excel (XLS, XLSX), CSV und Word (DOC, DOCX)'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$relatedEntity = $request->request->get('relatedEntity');
|
||||||
|
$relatedEntityId = $request->request->get('relatedEntityId');
|
||||||
|
|
||||||
|
if (!$relatedEntity || !$relatedEntityId) {
|
||||||
|
return $this->json(['error' => 'relatedEntity und relatedEntityId sind erforderlich'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Datei-Informationen VOR dem Move speichern
|
||||||
|
$originalFilename = $file->getClientOriginalName();
|
||||||
|
$mimeType = $file->getClientMimeType();
|
||||||
|
$fileSize = $file->getSize();
|
||||||
|
|
||||||
|
// Sichere Dateinamen generieren
|
||||||
|
$safeFilename = $this->slugger->slug(pathinfo($originalFilename, PATHINFO_FILENAME));
|
||||||
|
$newFilename = $safeFilename . '-' . uniqid() . '.' . $file->guessExtension();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$file->move($uploadsDirectory, $newFilename);
|
||||||
|
} catch (FileException $e) {
|
||||||
|
return $this->json(['error' => 'Datei konnte nicht gespeichert werden'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Document Entity erstellen
|
||||||
|
$document = new Document();
|
||||||
|
$document->setFilename($newFilename);
|
||||||
|
$document->setOriginalFilename($originalFilename);
|
||||||
|
$document->setMimeType($mimeType);
|
||||||
|
$document->setSize($fileSize);
|
||||||
|
$document->setRelatedEntity($relatedEntity);
|
||||||
|
$document->setRelatedEntityId((int) $relatedEntityId);
|
||||||
|
$document->setDescription($request->request->get('description'));
|
||||||
|
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->getUser();
|
||||||
|
$document->setUploadedBy($user);
|
||||||
|
|
||||||
|
$this->entityManager->persist($document);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
return $this->json($document, 201, [], ['groups' => 'document:read']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}/download', name: 'api_document_download', methods: ['GET'])]
|
||||||
|
public function download(Document $document): BinaryFileResponse
|
||||||
|
{
|
||||||
|
$this->denyAccessUnlessGranted('VIEW', $document);
|
||||||
|
|
||||||
|
$uploadsDirectory = str_replace('%kernel.project_dir%', $this->getParameter('kernel.project_dir'), $this->uploadsDirectory);
|
||||||
|
$filePath = $uploadsDirectory . '/' . $document->getFilename();
|
||||||
|
|
||||||
|
if (!file_exists($filePath)) {
|
||||||
|
throw $this->createNotFoundException('Datei nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = new BinaryFileResponse($filePath);
|
||||||
|
|
||||||
|
// Inline anzeigen statt Download für gängige Dateitypen
|
||||||
|
$mimeType = $document->getMimeType();
|
||||||
|
$inlineTypes = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
|
||||||
|
|
||||||
|
if (in_array($mimeType, $inlineTypes)) {
|
||||||
|
$response->setContentDisposition(
|
||||||
|
ResponseHeaderBag::DISPOSITION_INLINE,
|
||||||
|
$document->getOriginalFilename()
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$response->setContentDisposition(
|
||||||
|
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
|
||||||
|
$document->getOriginalFilename()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
409
src/Controller/GitRepositoryController.php
Normal file
409
src/Controller/GitRepositoryController.php
Normal file
@ -0,0 +1,409 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Entity\GitRepository;
|
||||||
|
use App\Service\GitService;
|
||||||
|
use App\Service\GitHubService;
|
||||||
|
use App\Service\GiteaService;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Routing\Annotation\Route;
|
||||||
|
|
||||||
|
#[Route('/api/git-repos')]
|
||||||
|
class GitRepositoryController extends AbstractController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private GitService $gitService,
|
||||||
|
private GitHubService $githubService,
|
||||||
|
private GiteaService $giteaService,
|
||||||
|
private EntityManagerInterface $em
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}/commits', name: 'api_git_repo_commits', methods: ['GET'])]
|
||||||
|
public function getCommits(GitRepository $gitRepo, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$limit = (int)$request->query->get('limit', 100);
|
||||||
|
$branch = $request->query->get('branch', $gitRepo->getBranch() ?? 'main');
|
||||||
|
|
||||||
|
// Choose service based on provider
|
||||||
|
if ($gitRepo->getProvider() === 'github') {
|
||||||
|
$commits = $this->getCommitsFromGitHub($gitRepo, $branch, $limit);
|
||||||
|
} elseif ($gitRepo->getProvider() === 'gitea') {
|
||||||
|
$commits = $this->getCommitsFromGitea($gitRepo, $branch, $limit);
|
||||||
|
} elseif ($gitRepo->getLocalPath()) {
|
||||||
|
$commits = $this->gitService->getCommits(
|
||||||
|
$gitRepo->getLocalPath(),
|
||||||
|
$branch,
|
||||||
|
$limit
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return $this->json([
|
||||||
|
'error' => 'No data source configured (neither provider API nor local path)'
|
||||||
|
], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last sync timestamp
|
||||||
|
$gitRepo->setLastSync(new \DateTime());
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'repository' => [
|
||||||
|
'id' => $gitRepo->getId(),
|
||||||
|
'name' => $gitRepo->getName(),
|
||||||
|
'branch' => $branch,
|
||||||
|
'provider' => $gitRepo->getProvider()
|
||||||
|
],
|
||||||
|
'commits' => $commits,
|
||||||
|
'total' => count($commits)
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json([
|
||||||
|
'error' => 'Failed to fetch commits: ' . $e->getMessage()
|
||||||
|
], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCommitsFromGitHub(GitRepository $gitRepo, string $branch, int $limit): array
|
||||||
|
{
|
||||||
|
$parsed = GitHubService::parseGitHubUrl($gitRepo->getUrl());
|
||||||
|
return $this->githubService->getCommits($parsed['owner'], $parsed['repo'], $branch, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getCommitsFromGitea(GitRepository $gitRepo, string $branch, int $limit): array
|
||||||
|
{
|
||||||
|
$parsed = GiteaService::parseGiteaUrl($gitRepo->getUrl());
|
||||||
|
return $this->giteaService->getCommits($parsed['baseUrl'], $parsed['owner'], $parsed['repo'], $branch, $limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}/contributions', name: 'api_git_repo_contributions', methods: ['GET'])]
|
||||||
|
public function getContributions(GitRepository $gitRepo, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$branch = $request->query->get('branch', $gitRepo->getBranch() ?? 'main');
|
||||||
|
$author = $request->query->get('author');
|
||||||
|
|
||||||
|
// Choose service based on provider
|
||||||
|
if ($gitRepo->getProvider() === 'github') {
|
||||||
|
$contributions = $this->getContributionsFromGitHub($gitRepo);
|
||||||
|
} elseif ($gitRepo->getProvider() === 'gitea') {
|
||||||
|
$contributions = $this->getContributionsFromGitea($gitRepo, $branch, $author);
|
||||||
|
} elseif ($gitRepo->getLocalPath()) {
|
||||||
|
$contributions = $this->gitService->getContributions(
|
||||||
|
$gitRepo->getLocalPath(),
|
||||||
|
$branch,
|
||||||
|
$author
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return $this->json([
|
||||||
|
'error' => 'No data source configured (neither provider API nor local path)'
|
||||||
|
], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last sync timestamp
|
||||||
|
$gitRepo->setLastSync(new \DateTime());
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'repository' => [
|
||||||
|
'id' => $gitRepo->getId(),
|
||||||
|
'name' => $gitRepo->getName(),
|
||||||
|
'branch' => $branch,
|
||||||
|
'provider' => $gitRepo->getProvider()
|
||||||
|
],
|
||||||
|
'contributions' => $contributions,
|
||||||
|
'total' => array_sum(array_column($contributions, 'count'))
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json([
|
||||||
|
'error' => 'Failed to fetch contributions: ' . $e->getMessage()
|
||||||
|
], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getContributionsFromGitHub(GitRepository $gitRepo): array
|
||||||
|
{
|
||||||
|
$parsed = GitHubService::parseGitHubUrl($gitRepo->getUrl());
|
||||||
|
return $this->githubService->getContributions($parsed['owner'], $parsed['repo']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getContributionsFromGitea(GitRepository $gitRepo, string $branch, ?string $author): array
|
||||||
|
{
|
||||||
|
$parsed = GiteaService::parseGiteaUrl($gitRepo->getUrl());
|
||||||
|
return $this->giteaService->getContributions($parsed['baseUrl'], $parsed['owner'], $parsed['repo'], $branch, $author);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}/branches', name: 'api_git_repo_branches', methods: ['GET'])]
|
||||||
|
public function getBranches(GitRepository $gitRepo): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
if (!$gitRepo->getLocalPath()) {
|
||||||
|
return $this->json([
|
||||||
|
'error' => 'No local path configured for this repository'
|
||||||
|
], Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$branches = $this->gitService->getBranches($gitRepo->getLocalPath());
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'repository' => [
|
||||||
|
'id' => $gitRepo->getId(),
|
||||||
|
'name' => $gitRepo->getName()
|
||||||
|
],
|
||||||
|
'branches' => $branches
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json([
|
||||||
|
'error' => 'Failed to fetch branches: ' . $e->getMessage()
|
||||||
|
], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/{id}/validate', name: 'api_git_repo_validate', methods: ['GET'])]
|
||||||
|
public function validateRepository(GitRepository $gitRepo): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$gitRepo->getLocalPath()) {
|
||||||
|
return $this->json([
|
||||||
|
'valid' => false,
|
||||||
|
'error' => 'No local path configured'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$isValid = $this->gitService->isValidRepository($gitRepo->getLocalPath());
|
||||||
|
|
||||||
|
if ($isValid) {
|
||||||
|
try {
|
||||||
|
$info = $this->gitService->getRepositoryInfo($gitRepo->getLocalPath());
|
||||||
|
return $this->json([
|
||||||
|
'valid' => true,
|
||||||
|
'info' => $info
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json([
|
||||||
|
'valid' => false,
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'valid' => false,
|
||||||
|
'error' => 'Invalid Git repository'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/test', name: 'api_git_repo_test_connection', methods: ['POST'])]
|
||||||
|
public function testConnection(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
// Simple authentication check - user must be logged in
|
||||||
|
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
|
||||||
|
|
||||||
|
$data = json_decode($request->getContent(), true);
|
||||||
|
|
||||||
|
$url = $data['url'] ?? null;
|
||||||
|
$provider = $data['provider'] ?? null;
|
||||||
|
$branch = $data['branch'] ?? 'main';
|
||||||
|
$accessToken = $data['accessToken'] ?? null;
|
||||||
|
$localPath = $data['localPath'] ?? null;
|
||||||
|
|
||||||
|
if (!$url) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'URL ist erforderlich'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test connection based on provider
|
||||||
|
if ($provider === 'github') {
|
||||||
|
return $this->testGitHubConnection($url, $branch, $accessToken);
|
||||||
|
} elseif ($provider === 'gitea') {
|
||||||
|
return $this->testGiteaConnection($url, $branch, $accessToken);
|
||||||
|
} elseif ($provider === 'local') {
|
||||||
|
return $this->testLocalConnection($localPath, $branch);
|
||||||
|
} else {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Unbekannter Provider',
|
||||||
|
'error' => 'Bitte wählen Sie einen gültigen Provider aus'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Fehler beim Testen der Verbindung',
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testGitHubConnection(string $url, string $branch, ?string $accessToken): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Parse GitHub URL
|
||||||
|
$repoInfo = $this->githubService->parseGitHubUrl($url);
|
||||||
|
if (!$repoInfo) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Ungültige GitHub URL',
|
||||||
|
'error' => 'Die URL konnte nicht geparst werden. Erwartet: https://github.com/owner/repo'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get repository info
|
||||||
|
try {
|
||||||
|
$info = $this->githubService->getRepositoryInfo(
|
||||||
|
$repoInfo['owner'],
|
||||||
|
$repoInfo['repo'],
|
||||||
|
$accessToken
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Repository-Informationen konnten nicht abgerufen werden',
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'details' => [
|
||||||
|
'owner' => $repoInfo['owner'],
|
||||||
|
'repo' => $repoInfo['repo'],
|
||||||
|
'hint' => 'Prüfen Sie, ob das Repository existiert und öffentlich ist, oder fügen Sie ein Access Token hinzu'
|
||||||
|
]
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get commits to verify branch access
|
||||||
|
try {
|
||||||
|
$commits = $this->githubService->getCommits($url, $branch, 10, $accessToken);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Branch konnte nicht gelesen werden',
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'details' => [
|
||||||
|
'branch' => $branch,
|
||||||
|
'defaultBranch' => $info['default_branch'] ?? 'main',
|
||||||
|
'hint' => 'Der Branch "' . $branch . '" existiert möglicherweise nicht. Standard-Branch ist: ' . ($info['default_branch'] ?? 'main')
|
||||||
|
]
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Verbindung erfolgreich',
|
||||||
|
'details' => [
|
||||||
|
'repositoryName' => $info['name'] ?? $repoInfo['repo'],
|
||||||
|
'defaultBranch' => $info['default_branch'] ?? 'main',
|
||||||
|
'commitCount' => count($commits),
|
||||||
|
'private' => $info['private'] ?? false
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Unerwarteter Fehler',
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testGiteaConnection(string $url, string $branch, ?string $accessToken): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Parse Gitea URL
|
||||||
|
$repoInfo = $this->giteaService->parseGiteaUrl($url);
|
||||||
|
if (!$repoInfo) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Ungültige Gitea URL',
|
||||||
|
'error' => 'Die URL konnte nicht geparst werden. Erwartet: https://gitea.example.com/owner/repo'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get commits with correct parameters
|
||||||
|
try {
|
||||||
|
$commits = $this->giteaService->getCommits(
|
||||||
|
$repoInfo['baseUrl'],
|
||||||
|
$repoInfo['owner'],
|
||||||
|
$repoInfo['repo'],
|
||||||
|
$branch,
|
||||||
|
10
|
||||||
|
);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Branch konnte nicht gelesen werden',
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'details' => [
|
||||||
|
'baseUrl' => $repoInfo['baseUrl'],
|
||||||
|
'owner' => $repoInfo['owner'],
|
||||||
|
'repo' => $repoInfo['repo'],
|
||||||
|
'branch' => $branch,
|
||||||
|
'hint' => 'Prüfen Sie, ob das Repository existiert und der Branch korrekt ist'
|
||||||
|
]
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Verbindung erfolgreich',
|
||||||
|
'details' => [
|
||||||
|
'repositoryName' => $repoInfo['repo'],
|
||||||
|
'commitCount' => count($commits),
|
||||||
|
'baseUrl' => $repoInfo['baseUrl'],
|
||||||
|
'owner' => $repoInfo['owner']
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Unerwarteter Fehler',
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function testLocalConnection(?string $localPath, string $branch): JsonResponse
|
||||||
|
{
|
||||||
|
if (!$localPath) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Lokaler Pfad ist erforderlich'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->gitService->isValidRepository($localPath)) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Kein gültiges Git-Repository',
|
||||||
|
'error' => 'Der angegebene Pfad enthält kein Git-Repository'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$branches = $this->gitService->getBranches($localPath);
|
||||||
|
$commits = $this->gitService->getCommits($localPath, $branch, 10);
|
||||||
|
|
||||||
|
return $this->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Repository gefunden und lesbar',
|
||||||
|
'details' => [
|
||||||
|
'path' => $localPath,
|
||||||
|
'branches' => count($branches),
|
||||||
|
'commitCount' => count($commits)
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $this->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Fehler beim Lesen des Repositories',
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,7 +24,7 @@ class PermissionController extends AbstractController
|
|||||||
$permissions = [];
|
$permissions = [];
|
||||||
|
|
||||||
// Liste aller Module die geprüft werden sollen
|
// Liste aller Module die geprüft werden sollen
|
||||||
$modules = ['contacts', 'projects', 'project_statuses', 'users', 'roles', 'settings'];
|
$modules = ['contacts', 'projects', 'project_statuses', 'documents', 'users', 'roles', 'settings'];
|
||||||
|
|
||||||
foreach ($modules as $module) {
|
foreach ($modules as $module) {
|
||||||
$permissions[$module] = [
|
$permissions[$module] = [
|
||||||
|
|||||||
196
src/Entity/Document.php
Normal file
196
src/Entity/Document.php
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiFilter;
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use App\Entity\Interface\ModuleAwareInterface;
|
||||||
|
use App\Repository\DocumentRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Annotation\Groups;
|
||||||
|
use Symfony\Component\Validator\Constraints as Assert;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: DocumentRepository::class)]
|
||||||
|
#[ORM\Table(name: 'documents')]
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||||
|
new Get(security: "is_granted('VIEW', object)"),
|
||||||
|
new Post(
|
||||||
|
security: "is_granted('CREATE', object)",
|
||||||
|
validationContext: ['groups' => ['Default', 'document:create']]
|
||||||
|
),
|
||||||
|
new Delete(security: "is_granted('DELETE', object)")
|
||||||
|
],
|
||||||
|
normalizationContext: ['groups' => ['document:read']],
|
||||||
|
denormalizationContext: ['groups' => ['document:write']]
|
||||||
|
)]
|
||||||
|
#[ApiFilter(SearchFilter::class, properties: ['relatedEntity' => 'exact', 'relatedEntityId' => 'exact'])]
|
||||||
|
class Document implements ModuleAwareInterface
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['document:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
#[Groups(['document:read'])]
|
||||||
|
private ?string $filename = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255)]
|
||||||
|
#[Groups(['document:read'])]
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
private ?string $originalFilename = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100)]
|
||||||
|
#[Groups(['document:read'])]
|
||||||
|
private ?string $mimeType = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['document:read'])]
|
||||||
|
private ?int $size = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_MUTABLE)]
|
||||||
|
#[Groups(['document:read'])]
|
||||||
|
private ?\DateTimeInterface $uploadedAt = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
#[Groups(['document:read'])]
|
||||||
|
private ?User $uploadedBy = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 50)]
|
||||||
|
#[Groups(['document:read', 'document:write'])]
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
private ?string $relatedEntity = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['document:read', 'document:write'])]
|
||||||
|
#[Assert\NotBlank]
|
||||||
|
private ?int $relatedEntityId = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
#[Groups(['document:read', 'document:write'])]
|
||||||
|
private ?string $description = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->uploadedAt = new \DateTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFilename(): ?string
|
||||||
|
{
|
||||||
|
return $this->filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFilename(string $filename): static
|
||||||
|
{
|
||||||
|
$this->filename = $filename;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOriginalFilename(): ?string
|
||||||
|
{
|
||||||
|
return $this->originalFilename;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setOriginalFilename(string $originalFilename): static
|
||||||
|
{
|
||||||
|
$this->originalFilename = $originalFilename;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMimeType(): ?string
|
||||||
|
{
|
||||||
|
return $this->mimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setMimeType(string $mimeType): static
|
||||||
|
{
|
||||||
|
$this->mimeType = $mimeType;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getSize(): ?int
|
||||||
|
{
|
||||||
|
return $this->size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setSize(int $size): static
|
||||||
|
{
|
||||||
|
$this->size = $size;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUploadedAt(): ?\DateTimeInterface
|
||||||
|
{
|
||||||
|
return $this->uploadedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUploadedAt(\DateTimeInterface $uploadedAt): static
|
||||||
|
{
|
||||||
|
$this->uploadedAt = $uploadedAt;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUploadedBy(): ?User
|
||||||
|
{
|
||||||
|
return $this->uploadedBy;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUploadedBy(?User $uploadedBy): static
|
||||||
|
{
|
||||||
|
$this->uploadedBy = $uploadedBy;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRelatedEntity(): ?string
|
||||||
|
{
|
||||||
|
return $this->relatedEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRelatedEntity(string $relatedEntity): static
|
||||||
|
{
|
||||||
|
$this->relatedEntity = $relatedEntity;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRelatedEntityId(): ?int
|
||||||
|
{
|
||||||
|
return $this->relatedEntityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setRelatedEntityId(int $relatedEntityId): static
|
||||||
|
{
|
||||||
|
$this->relatedEntityId = $relatedEntityId;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): ?string
|
||||||
|
{
|
||||||
|
return $this->description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDescription(?string $description): static
|
||||||
|
{
|
||||||
|
$this->description = $description;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getModuleName(): string
|
||||||
|
{
|
||||||
|
return 'documents';
|
||||||
|
}
|
||||||
|
}
|
||||||
229
src/Entity/GitRepository.php
Normal file
229
src/Entity/GitRepository.php
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
|
use ApiPlatform\Metadata\Put;
|
||||||
|
use ApiPlatform\Metadata\Delete;
|
||||||
|
use App\Repository\GitRepositoryRepository;
|
||||||
|
use Doctrine\DBAL\Types\Types;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Annotation\Groups;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: GitRepositoryRepository::class)]
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(normalizationContext: ['groups' => ['git_repo:read', 'git_repo:read:detail']]),
|
||||||
|
new GetCollection(normalizationContext: ['groups' => ['git_repo:read']]),
|
||||||
|
new Post(denormalizationContext: ['groups' => ['git_repo:write']]),
|
||||||
|
new Put(denormalizationContext: ['groups' => ['git_repo:write']]),
|
||||||
|
new Delete()
|
||||||
|
],
|
||||||
|
normalizationContext: ['groups' => ['git_repo:read']],
|
||||||
|
denormalizationContext: ['groups' => ['git_repo:write']]
|
||||||
|
)]
|
||||||
|
class GitRepository
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['git_repo:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 500)]
|
||||||
|
#[Groups(['git_repo:read', 'git_repo:write'])]
|
||||||
|
private ?string $url = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 500, nullable: true)]
|
||||||
|
#[Groups(['git_repo:read', 'git_repo:write'])]
|
||||||
|
private ?string $localPath = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 100, nullable: true)]
|
||||||
|
#[Groups(['git_repo:read', 'git_repo:write'])]
|
||||||
|
private ?string $branch = 'main';
|
||||||
|
|
||||||
|
#[ORM\Column(length: 50, nullable: true)]
|
||||||
|
#[Groups(['git_repo:read', 'git_repo:write'])]
|
||||||
|
private ?string $provider = 'github'; // github, gitlab, bitbucket, local
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
#[Groups(['git_repo:read', 'git_repo:write'])]
|
||||||
|
private ?string $accessToken = null; // For API access (encrypted in production!)
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(inversedBy: 'gitRepositories')]
|
||||||
|
#[ORM\JoinColumn(nullable: false)]
|
||||||
|
#[Groups(['git_repo:read', 'git_repo:write'])]
|
||||||
|
private ?Project $project = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
|
||||||
|
#[Groups(['git_repo:read'])]
|
||||||
|
private ?\DateTimeInterface $lastSync = null;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
|
#[Groups(['git_repo:read', 'git_repo:write'])]
|
||||||
|
private ?string $name = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||||
|
#[Groups(['git_repo:read', 'git_repo:write'])]
|
||||||
|
private ?string $description = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['git_repo:read'])]
|
||||||
|
private ?\DateTimeImmutable $createdAt = null;
|
||||||
|
|
||||||
|
#[ORM\Column]
|
||||||
|
#[Groups(['git_repo:read'])]
|
||||||
|
private ?\DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ORM\PreUpdate]
|
||||||
|
public function setUpdatedAtValue(): void
|
||||||
|
{
|
||||||
|
$this->updatedAt = new \DateTimeImmutable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUrl(): ?string
|
||||||
|
{
|
||||||
|
return $this->url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUrl(string $url): static
|
||||||
|
{
|
||||||
|
$this->url = $url;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLocalPath(): ?string
|
||||||
|
{
|
||||||
|
return $this->localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLocalPath(?string $localPath): static
|
||||||
|
{
|
||||||
|
$this->localPath = $localPath;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getBranch(): ?string
|
||||||
|
{
|
||||||
|
return $this->branch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setBranch(?string $branch): static
|
||||||
|
{
|
||||||
|
$this->branch = $branch;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProject(): ?Project
|
||||||
|
{
|
||||||
|
return $this->project;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setProject(?Project $project): static
|
||||||
|
{
|
||||||
|
$this->project = $project;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLastSync(): ?\DateTimeInterface
|
||||||
|
{
|
||||||
|
return $this->lastSync;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setLastSync(?\DateTimeInterface $lastSync): static
|
||||||
|
{
|
||||||
|
$this->lastSync = $lastSync;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): ?string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(?string $name): static
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): ?string
|
||||||
|
{
|
||||||
|
return $this->description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDescription(?string $description): static
|
||||||
|
{
|
||||||
|
$this->description = $description;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->createdAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setCreatedAt(\DateTimeImmutable $createdAt): static
|
||||||
|
{
|
||||||
|
$this->createdAt = $createdAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUpdatedAt(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setUpdatedAt(\DateTimeImmutable $updatedAt): static
|
||||||
|
{
|
||||||
|
$this->updatedAt = $updatedAt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProvider(): ?string
|
||||||
|
{
|
||||||
|
return $this->provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setProvider(?string $provider): static
|
||||||
|
{
|
||||||
|
$this->provider = $provider;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAccessToken(): ?string
|
||||||
|
{
|
||||||
|
return $this->accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setAccessToken(?string $accessToken): static
|
||||||
|
{
|
||||||
|
$this->accessToken = $accessToken;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,8 @@ use ApiPlatform\Metadata\Put;
|
|||||||
use ApiPlatform\Metadata\Delete;
|
use ApiPlatform\Metadata\Delete;
|
||||||
use App\Entity\Interface\ModuleAwareInterface;
|
use App\Entity\Interface\ModuleAwareInterface;
|
||||||
use App\Repository\ProjectRepository;
|
use App\Repository\ProjectRepository;
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use Symfony\Component\Serializer\Annotation\Groups;
|
use Symfony\Component\Serializer\Annotation\Groups;
|
||||||
@ -126,9 +128,14 @@ class Project implements ModuleAwareInterface
|
|||||||
#[Groups(['project:read'])]
|
#[Groups(['project:read'])]
|
||||||
private ?\DateTimeImmutable $updatedAt = null;
|
private ?\DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
#[ORM\OneToMany(mappedBy: 'project', targetEntity: GitRepository::class, cascade: ['remove'])]
|
||||||
|
#[Groups(['project:read'])]
|
||||||
|
private Collection $gitRepositories;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->createdAt = new \DateTimeImmutable();
|
$this->createdAt = new \DateTimeImmutable();
|
||||||
|
$this->gitRepositories = new ArrayCollection();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getId(): ?int
|
public function getId(): ?int
|
||||||
@ -307,6 +314,36 @@ class Project implements ModuleAwareInterface
|
|||||||
return $this->name ?? '';
|
return $this->name ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<int, GitRepository>
|
||||||
|
*/
|
||||||
|
public function getGitRepositories(): Collection
|
||||||
|
{
|
||||||
|
return $this->gitRepositories;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addGitRepository(GitRepository $gitRepository): static
|
||||||
|
{
|
||||||
|
if (!$this->gitRepositories->contains($gitRepository)) {
|
||||||
|
$this->gitRepositories->add($gitRepository);
|
||||||
|
$gitRepository->setProject($this);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeGitRepository(GitRepository $gitRepository): static
|
||||||
|
{
|
||||||
|
if ($this->gitRepositories->removeElement($gitRepository)) {
|
||||||
|
// set the owning side to null (unless already changed)
|
||||||
|
if ($gitRepository->getProject() === $this) {
|
||||||
|
$gitRepository->setProject(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the module code this entity belongs to.
|
* Returns the module code this entity belongs to.
|
||||||
* Required by ModuleVoter for permission checks.
|
* Required by ModuleVoter for permission checks.
|
||||||
|
|||||||
@ -37,7 +37,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[ORM\Id]
|
#[ORM\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
#[Groups(['user:read'])]
|
#[Groups(['user:read', 'document:read'])]
|
||||||
private ?int $id = null;
|
private ?int $id = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 180)]
|
#[ORM\Column(length: 180)]
|
||||||
@ -47,11 +47,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
private ?string $email = null;
|
private ?string $email = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 100)]
|
#[ORM\Column(length: 100)]
|
||||||
#[Groups(['user:read', 'user:write'])]
|
#[Groups(['user:read', 'user:write', 'document:read'])]
|
||||||
private ?string $firstName = null;
|
private ?string $firstName = null;
|
||||||
|
|
||||||
#[ORM\Column(length: 100)]
|
#[ORM\Column(length: 100)]
|
||||||
#[Groups(['user:read', 'user:write'])]
|
#[Groups(['user:read', 'user:write', 'document:read'])]
|
||||||
private ?string $lastName = null;
|
private ?string $lastName = null;
|
||||||
|
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
|
|||||||
@ -79,7 +79,7 @@ class MailerLoggerListener
|
|||||||
private function logSentEmail(SentMessageEvent $event): void
|
private function logSentEmail(SentMessageEvent $event): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$rawMessage = $event->getMessage()->getMessage();
|
$rawMessage = $event->getMessage();
|
||||||
|
|
||||||
if (!$rawMessage instanceof Email) {
|
if (!$rawMessage instanceof Email) {
|
||||||
return;
|
return;
|
||||||
@ -117,7 +117,7 @@ class MailerLoggerListener
|
|||||||
private function logFailedEmail(FailedMessageEvent $event): void
|
private function logFailedEmail(FailedMessageEvent $event): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$rawMessage = $event->getMessage()->getMessage();
|
$rawMessage = $event->getMessage();
|
||||||
|
|
||||||
if (!$rawMessage instanceof Email) {
|
if (!$rawMessage instanceof Email) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
46
src/Repository/DocumentRepository.php
Normal file
46
src/Repository/DocumentRepository.php
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Document;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<Document>
|
||||||
|
*/
|
||||||
|
class DocumentRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, Document::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all documents for a specific entity
|
||||||
|
*/
|
||||||
|
public function findByRelatedEntity(string $entityType, int $entityId): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('d')
|
||||||
|
->andWhere('d.relatedEntity = :entityType')
|
||||||
|
->andWhere('d.relatedEntityId = :entityId')
|
||||||
|
->setParameter('entityType', $entityType)
|
||||||
|
->setParameter('entityId', $entityId)
|
||||||
|
->orderBy('d.uploadedAt', 'DESC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find documents by user
|
||||||
|
*/
|
||||||
|
public function findByUser(int $userId): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('d')
|
||||||
|
->andWhere('d.uploadedBy = :userId')
|
||||||
|
->setParameter('userId', $userId)
|
||||||
|
->orderBy('d.uploadedAt', 'DESC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/Repository/GitRepositoryRepository.php
Normal file
31
src/Repository/GitRepositoryRepository.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\GitRepository;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<GitRepository>
|
||||||
|
*/
|
||||||
|
class GitRepositoryRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, GitRepository::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all repositories for a specific project
|
||||||
|
*/
|
||||||
|
public function findByProject(int $projectId): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('g')
|
||||||
|
->andWhere('g.project = :projectId')
|
||||||
|
->setParameter('projectId', $projectId)
|
||||||
|
->orderBy('g.name', 'ASC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
190
src/Service/GitHubService.php
Normal file
190
src/Service/GitHubService.php
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
class GitHubService
|
||||||
|
{
|
||||||
|
private const GITHUB_API = 'https://api.github.com';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private HttpClientInterface $httpClient,
|
||||||
|
private ?string $githubToken = null // Optional: aus .env laden für höhere Rate Limits
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get commits from a GitHub repository
|
||||||
|
*
|
||||||
|
* @param string $owner Repository owner (user/organization)
|
||||||
|
* @param string $repo Repository name
|
||||||
|
* @param string $branch Branch name (default: main)
|
||||||
|
* @param int $perPage Commits per page (max 100)
|
||||||
|
* @return array Commits data
|
||||||
|
*/
|
||||||
|
public function getCommits(string $owner, string $repo, string $branch = 'main', int $perPage = 100): array
|
||||||
|
{
|
||||||
|
$url = sprintf('%s/repos/%s/%s/commits', self::GITHUB_API, $owner, $repo);
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
'query' => [
|
||||||
|
'sha' => $branch,
|
||||||
|
'per_page' => min($perPage, 100)
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->githubToken) {
|
||||||
|
$options['headers'] = [
|
||||||
|
'Authorization' => 'Bearer ' . $this->githubToken
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->request('GET', $url, $options);
|
||||||
|
$commits = $response->toArray();
|
||||||
|
|
||||||
|
return array_map(function ($commit) {
|
||||||
|
return [
|
||||||
|
'hash' => $commit['sha'],
|
||||||
|
'shortHash' => substr($commit['sha'], 0, 7),
|
||||||
|
'author' => $commit['commit']['author']['name'],
|
||||||
|
'email' => $commit['commit']['author']['email'],
|
||||||
|
'date' => $commit['commit']['author']['date'],
|
||||||
|
'subject' => $commit['commit']['message'],
|
||||||
|
'body' => '',
|
||||||
|
'url' => $commit['html_url']
|
||||||
|
];
|
||||||
|
}, $commits);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new \RuntimeException('Failed to fetch commits from GitHub: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get contribution statistics (commit activity)
|
||||||
|
*
|
||||||
|
* @param string $owner Repository owner
|
||||||
|
* @param string $repo Repository name
|
||||||
|
* @return array Weekly contribution data
|
||||||
|
*/
|
||||||
|
public function getContributions(string $owner, string $repo): array
|
||||||
|
{
|
||||||
|
$url = sprintf('%s/repos/%s/%s/stats/commit_activity', self::GITHUB_API, $owner, $repo);
|
||||||
|
|
||||||
|
$options = [];
|
||||||
|
if ($this->githubToken) {
|
||||||
|
$options['headers'] = [
|
||||||
|
'Authorization' => 'Bearer ' . $this->githubToken
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->request('GET', $url, $options);
|
||||||
|
$weeklyStats = $response->toArray();
|
||||||
|
|
||||||
|
return array_map(function ($week) {
|
||||||
|
$date = new \DateTime('@' . $week['week']);
|
||||||
|
return [
|
||||||
|
'week' => $date->format('Y-W'),
|
||||||
|
'year' => (int)$date->format('Y'),
|
||||||
|
'weekNumber' => (int)$date->format('W'),
|
||||||
|
'startDate' => $date->format('Y-m-d'),
|
||||||
|
'count' => $week['total']
|
||||||
|
];
|
||||||
|
}, $weeklyStats);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new \RuntimeException('Failed to fetch contribution stats from GitHub: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get branches from a GitHub repository
|
||||||
|
*
|
||||||
|
* @param string $owner Repository owner
|
||||||
|
* @param string $repo Repository name
|
||||||
|
* @return array Branch names
|
||||||
|
*/
|
||||||
|
public function getBranches(string $owner, string $repo): array
|
||||||
|
{
|
||||||
|
$url = sprintf('%s/repos/%s/%s/branches', self::GITHUB_API, $owner, $repo);
|
||||||
|
|
||||||
|
$options = [];
|
||||||
|
if ($this->githubToken) {
|
||||||
|
$options['headers'] = [
|
||||||
|
'Authorization' => 'Bearer ' . $this->githubToken
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->request('GET', $url, $options);
|
||||||
|
$branches = $response->toArray();
|
||||||
|
|
||||||
|
return array_map(fn($branch) => $branch['name'], $branches);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new \RuntimeException('Failed to fetch branches from GitHub: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse GitHub URL to extract owner and repo
|
||||||
|
*
|
||||||
|
* @param string $url GitHub repository URL
|
||||||
|
* @return array ['owner' => string, 'repo' => string]
|
||||||
|
*/
|
||||||
|
public static function parseGitHubUrl(string $url): array
|
||||||
|
{
|
||||||
|
// Supports:
|
||||||
|
// https://github.com/owner/repo
|
||||||
|
// https://github.com/owner/repo.git
|
||||||
|
// git@github.com:owner/repo.git
|
||||||
|
|
||||||
|
$pattern = '#github\.com[:/]([^/]+)/([^/\.]+)(?:\.git)?#';
|
||||||
|
|
||||||
|
if (preg_match($pattern, $url, $matches)) {
|
||||||
|
return [
|
||||||
|
'owner' => $matches[1],
|
||||||
|
'repo' => $matches[2]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \InvalidArgumentException('Invalid GitHub URL format');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get repository info
|
||||||
|
*
|
||||||
|
* @param string $owner Repository owner
|
||||||
|
* @param string $repo Repository name
|
||||||
|
* @return array Repository information
|
||||||
|
*/
|
||||||
|
public function getRepositoryInfo(string $owner, string $repo): array
|
||||||
|
{
|
||||||
|
$url = sprintf('%s/repos/%s/%s', self::GITHUB_API, $owner, $repo);
|
||||||
|
|
||||||
|
$options = [];
|
||||||
|
if ($this->githubToken) {
|
||||||
|
$options['headers'] = [
|
||||||
|
'Authorization' => 'Bearer ' . $this->githubToken
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->request('GET', $url, $options);
|
||||||
|
$data = $response->toArray();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $data['name'],
|
||||||
|
'fullName' => $data['full_name'],
|
||||||
|
'description' => $data['description'],
|
||||||
|
'defaultBranch' => $data['default_branch'],
|
||||||
|
'url' => $data['html_url'],
|
||||||
|
'language' => $data['language'],
|
||||||
|
'stars' => $data['stargazers_count'],
|
||||||
|
'forks' => $data['forks_count']
|
||||||
|
];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new \RuntimeException('Failed to fetch repository info from GitHub: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
227
src/Service/GitService.php
Normal file
227
src/Service/GitService.php
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use Symfony\Component\Process\Exception\ProcessFailedException;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
class GitService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get commits from a Git repository
|
||||||
|
*
|
||||||
|
* @param string $repositoryPath Path to the Git repository
|
||||||
|
* @param string $branch Branch name (default: main)
|
||||||
|
* @param int $limit Number of commits to fetch (default: 100)
|
||||||
|
* @return array Array of commit data
|
||||||
|
*/
|
||||||
|
public function getCommits(string $repositoryPath, string $branch = 'main', int $limit = 100): array
|
||||||
|
{
|
||||||
|
if (!is_dir($repositoryPath) || !is_dir($repositoryPath . '/.git')) {
|
||||||
|
throw new \InvalidArgumentException('Invalid Git repository path');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Git log format: hash|author name|author email|date|subject|body
|
||||||
|
$format = '%H|%an|%ae|%aI|%s|%b';
|
||||||
|
|
||||||
|
$process = new Process([
|
||||||
|
'git',
|
||||||
|
'-C',
|
||||||
|
$repositoryPath,
|
||||||
|
'log',
|
||||||
|
$branch,
|
||||||
|
'--format=' . $format,
|
||||||
|
'-n',
|
||||||
|
(string)$limit
|
||||||
|
]);
|
||||||
|
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (!$process->isSuccessful()) {
|
||||||
|
throw new ProcessFailedException($process);
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = $process->getOutput();
|
||||||
|
$commits = [];
|
||||||
|
|
||||||
|
// Split output by newline, filter empty lines
|
||||||
|
$lines = array_filter(explode("\n", trim($output)));
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$parts = explode('|', $line, 6);
|
||||||
|
|
||||||
|
if (count($parts) >= 5) {
|
||||||
|
$commits[] = [
|
||||||
|
'hash' => $parts[0],
|
||||||
|
'shortHash' => substr($parts[0], 0, 7),
|
||||||
|
'author' => $parts[1],
|
||||||
|
'email' => $parts[2],
|
||||||
|
'date' => $parts[3],
|
||||||
|
'subject' => $parts[4],
|
||||||
|
'body' => $parts[5] ?? ''
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $commits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get contribution statistics grouped by week
|
||||||
|
*
|
||||||
|
* @param string $repositoryPath Path to the Git repository
|
||||||
|
* @param string $branch Branch name (default: main)
|
||||||
|
* @param string|null $author Filter by author email (optional)
|
||||||
|
* @return array Array of weekly contribution data
|
||||||
|
*/
|
||||||
|
public function getContributions(string $repositoryPath, string $branch = 'main', ?string $author = null): array
|
||||||
|
{
|
||||||
|
if (!is_dir($repositoryPath) || !is_dir($repositoryPath . '/.git')) {
|
||||||
|
throw new \InvalidArgumentException('Invalid Git repository path');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get commits from the last year
|
||||||
|
$since = (new \DateTime())->modify('-1 year')->format('Y-m-d');
|
||||||
|
|
||||||
|
$command = [
|
||||||
|
'git',
|
||||||
|
'-C',
|
||||||
|
$repositoryPath,
|
||||||
|
'log',
|
||||||
|
$branch,
|
||||||
|
'--format=%aI',
|
||||||
|
'--since=' . $since
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($author) {
|
||||||
|
$command[] = '--author=' . $author;
|
||||||
|
}
|
||||||
|
|
||||||
|
$process = new Process($command);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (!$process->isSuccessful()) {
|
||||||
|
throw new ProcessFailedException($process);
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = $process->getOutput();
|
||||||
|
$dates = array_filter(explode("\n", trim($output)));
|
||||||
|
|
||||||
|
// Group commits by week
|
||||||
|
$weeklyContributions = [];
|
||||||
|
|
||||||
|
foreach ($dates as $dateString) {
|
||||||
|
$date = new \DateTime($dateString);
|
||||||
|
$weekKey = $date->format('Y-W'); // Year-Week format
|
||||||
|
|
||||||
|
if (!isset($weeklyContributions[$weekKey])) {
|
||||||
|
$weeklyContributions[$weekKey] = [
|
||||||
|
'week' => $weekKey,
|
||||||
|
'year' => (int)$date->format('Y'),
|
||||||
|
'weekNumber' => (int)$date->format('W'),
|
||||||
|
'startDate' => $date->modify('monday this week')->format('Y-m-d'),
|
||||||
|
'count' => 0
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$weeklyContributions[$weekKey]['count']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by week
|
||||||
|
ksort($weeklyContributions);
|
||||||
|
|
||||||
|
return array_values($weeklyContributions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of branches in the repository
|
||||||
|
*
|
||||||
|
* @param string $repositoryPath Path to the Git repository
|
||||||
|
* @return array Array of branch names
|
||||||
|
*/
|
||||||
|
public function getBranches(string $repositoryPath): array
|
||||||
|
{
|
||||||
|
if (!is_dir($repositoryPath) || !is_dir($repositoryPath . '/.git')) {
|
||||||
|
throw new \InvalidArgumentException('Invalid Git repository path');
|
||||||
|
}
|
||||||
|
|
||||||
|
$process = new Process([
|
||||||
|
'git',
|
||||||
|
'-C',
|
||||||
|
$repositoryPath,
|
||||||
|
'branch',
|
||||||
|
'-r'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (!$process->isSuccessful()) {
|
||||||
|
throw new ProcessFailedException($process);
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = $process->getOutput();
|
||||||
|
$branches = [];
|
||||||
|
|
||||||
|
foreach (explode("\n", trim($output)) as $line) {
|
||||||
|
$branch = trim(str_replace('origin/', '', $line));
|
||||||
|
if ($branch && $branch !== 'HEAD') {
|
||||||
|
$branches[] = $branch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $branches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate if a path is a valid Git repository
|
||||||
|
*
|
||||||
|
* @param string $repositoryPath Path to check
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function isValidRepository(string $repositoryPath): bool
|
||||||
|
{
|
||||||
|
return is_dir($repositoryPath) && is_dir($repositoryPath . '/.git');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get repository info (current branch, remote URL, etc.)
|
||||||
|
*
|
||||||
|
* @param string $repositoryPath Path to the Git repository
|
||||||
|
* @return array Repository information
|
||||||
|
*/
|
||||||
|
public function getRepositoryInfo(string $repositoryPath): array
|
||||||
|
{
|
||||||
|
if (!$this->isValidRepository($repositoryPath)) {
|
||||||
|
throw new \InvalidArgumentException('Invalid Git repository path');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current branch
|
||||||
|
$branchProcess = new Process([
|
||||||
|
'git',
|
||||||
|
'-C',
|
||||||
|
$repositoryPath,
|
||||||
|
'rev-parse',
|
||||||
|
'--abbrev-ref',
|
||||||
|
'HEAD'
|
||||||
|
]);
|
||||||
|
$branchProcess->run();
|
||||||
|
$currentBranch = trim($branchProcess->getOutput());
|
||||||
|
|
||||||
|
// Get remote URL
|
||||||
|
$remoteProcess = new Process([
|
||||||
|
'git',
|
||||||
|
'-C',
|
||||||
|
$repositoryPath,
|
||||||
|
'config',
|
||||||
|
'--get',
|
||||||
|
'remote.origin.url'
|
||||||
|
]);
|
||||||
|
$remoteProcess->run();
|
||||||
|
$remoteUrl = trim($remoteProcess->getOutput());
|
||||||
|
|
||||||
|
return [
|
||||||
|
'currentBranch' => $currentBranch,
|
||||||
|
'remoteUrl' => $remoteUrl
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
246
src/Service/GiteaService.php
Normal file
246
src/Service/GiteaService.php
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
class GiteaService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private HttpClientInterface $httpClient,
|
||||||
|
private ?string $giteaToken = null // Optional: aus .env laden
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get commits from a Gitea repository
|
||||||
|
*
|
||||||
|
* @param string $baseUrl Gitea instance URL (e.g., https://gitea.example.com)
|
||||||
|
* @param string $owner Repository owner (user/organization)
|
||||||
|
* @param string $repo Repository name
|
||||||
|
* @param string $branch Branch name (default: main)
|
||||||
|
* @param int $perPage Commits per page (max 50)
|
||||||
|
* @return array Commits data
|
||||||
|
*/
|
||||||
|
public function getCommits(string $baseUrl, string $owner, string $repo, string $branch = 'main', int $perPage = 50): array
|
||||||
|
{
|
||||||
|
$url = sprintf('%s/api/v1/repos/%s/%s/commits', rtrim($baseUrl, '/'), $owner, $repo);
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
'query' => [
|
||||||
|
'sha' => $branch,
|
||||||
|
'limit' => min($perPage, 50)
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->giteaToken) {
|
||||||
|
$options['headers'] = [
|
||||||
|
'Authorization' => 'token ' . $this->giteaToken
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->request('GET', $url, $options);
|
||||||
|
$commits = $response->toArray();
|
||||||
|
|
||||||
|
return array_map(function ($commit) use ($baseUrl, $owner, $repo) {
|
||||||
|
return [
|
||||||
|
'hash' => $commit['sha'],
|
||||||
|
'shortHash' => substr($commit['sha'], 0, 7),
|
||||||
|
'author' => $commit['commit']['author']['name'],
|
||||||
|
'email' => $commit['commit']['author']['email'],
|
||||||
|
'date' => $commit['commit']['author']['date'],
|
||||||
|
'subject' => $commit['commit']['message'],
|
||||||
|
'body' => '',
|
||||||
|
'url' => sprintf('%s/%s/%s/commit/%s', rtrim($baseUrl, '/'), $owner, $repo, $commit['sha'])
|
||||||
|
];
|
||||||
|
}, $commits);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new \RuntimeException('Failed to fetch commits from Gitea: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get contribution statistics
|
||||||
|
* Note: Gitea doesn't have a built-in commit activity endpoint like GitHub,
|
||||||
|
* so we fetch commits and aggregate them
|
||||||
|
*
|
||||||
|
* @param string $baseUrl Gitea instance URL
|
||||||
|
* @param string $owner Repository owner
|
||||||
|
* @param string $repo Repository name
|
||||||
|
* @param string $branch Branch name
|
||||||
|
* @param string|null $author Filter by author email
|
||||||
|
* @return array Weekly contribution data
|
||||||
|
*/
|
||||||
|
public function getContributions(string $baseUrl, string $owner, string $repo, string $branch = 'main', ?string $author = null): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Fetch commits (Gitea limits to 50 per request, may need pagination for full year)
|
||||||
|
$commits = $this->getCommits($baseUrl, $owner, $repo, $branch, 50);
|
||||||
|
|
||||||
|
// Filter by author if specified
|
||||||
|
if ($author) {
|
||||||
|
$commits = array_filter($commits, fn($commit) => stripos($commit['email'], $author) !== false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by week
|
||||||
|
$weeklyContributions = [];
|
||||||
|
$oneYearAgo = new \DateTime('-1 year');
|
||||||
|
|
||||||
|
foreach ($commits as $commit) {
|
||||||
|
$date = new \DateTime($commit['date']);
|
||||||
|
|
||||||
|
// Only include commits from last year
|
||||||
|
if ($date < $oneYearAgo) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$weekKey = $date->format('Y-W');
|
||||||
|
|
||||||
|
if (!isset($weeklyContributions[$weekKey])) {
|
||||||
|
$weeklyContributions[$weekKey] = [
|
||||||
|
'week' => $weekKey,
|
||||||
|
'year' => (int)$date->format('Y'),
|
||||||
|
'weekNumber' => (int)$date->format('W'),
|
||||||
|
'startDate' => (clone $date)->modify('monday this week')->format('Y-m-d'),
|
||||||
|
'count' => 0
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$weeklyContributions[$weekKey]['count']++;
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($weeklyContributions);
|
||||||
|
return array_values($weeklyContributions);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new \RuntimeException('Failed to fetch contribution stats from Gitea: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get branches from a Gitea repository
|
||||||
|
*
|
||||||
|
* @param string $baseUrl Gitea instance URL
|
||||||
|
* @param string $owner Repository owner
|
||||||
|
* @param string $repo Repository name
|
||||||
|
* @return array Branch names
|
||||||
|
*/
|
||||||
|
public function getBranches(string $baseUrl, string $owner, string $repo): array
|
||||||
|
{
|
||||||
|
$url = sprintf('%s/api/v1/repos/%s/%s/branches', rtrim($baseUrl, '/'), $owner, $repo);
|
||||||
|
|
||||||
|
$options = [];
|
||||||
|
if ($this->giteaToken) {
|
||||||
|
$options['headers'] = [
|
||||||
|
'Authorization' => 'token ' . $this->giteaToken
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->request('GET', $url, $options);
|
||||||
|
$branches = $response->toArray();
|
||||||
|
|
||||||
|
return array_map(fn($branch) => $branch['name'], $branches);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new \RuntimeException('Failed to fetch branches from Gitea: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Gitea URL to extract base URL, owner and repo
|
||||||
|
*
|
||||||
|
* @param string $url Gitea repository URL
|
||||||
|
* @return array ['baseUrl' => string, 'owner' => string, 'repo' => string]
|
||||||
|
*/
|
||||||
|
public static function parseGiteaUrl(string $url): array
|
||||||
|
{
|
||||||
|
// Supports:
|
||||||
|
// https://gitea.example.com/owner/repo
|
||||||
|
// https://gitea.example.com/owner/repo.git
|
||||||
|
// git@gitea.example.com:owner/repo.git
|
||||||
|
|
||||||
|
// Handle SSH format
|
||||||
|
if (preg_match('#^git@([^:]+):([^/]+)/([^/\.]+)(?:\.git)?$#', $url, $matches)) {
|
||||||
|
return [
|
||||||
|
'baseUrl' => 'https://' . $matches[1],
|
||||||
|
'owner' => $matches[2],
|
||||||
|
'repo' => $matches[3]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle HTTPS format
|
||||||
|
if (preg_match('#^https?://([^/]+)/([^/]+)/([^/\.]+)(?:\.git)?$#', $url, $matches)) {
|
||||||
|
return [
|
||||||
|
'baseUrl' => 'https://' . $matches[1],
|
||||||
|
'owner' => $matches[2],
|
||||||
|
'repo' => $matches[3]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \InvalidArgumentException('Invalid Gitea URL format');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get repository info
|
||||||
|
*
|
||||||
|
* @param string $baseUrl Gitea instance URL
|
||||||
|
* @param string $owner Repository owner
|
||||||
|
* @param string $repo Repository name
|
||||||
|
* @return array Repository information
|
||||||
|
*/
|
||||||
|
public function getRepositoryInfo(string $baseUrl, string $owner, string $repo): array
|
||||||
|
{
|
||||||
|
$url = sprintf('%s/api/v1/repos/%s/%s', rtrim($baseUrl, '/'), $owner, $repo);
|
||||||
|
|
||||||
|
$options = [];
|
||||||
|
if ($this->giteaToken) {
|
||||||
|
$options['headers'] = [
|
||||||
|
'Authorization' => 'token ' . $this->giteaToken
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->request('GET', $url, $options);
|
||||||
|
$data = $response->toArray();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $data['name'],
|
||||||
|
'fullName' => $data['full_name'],
|
||||||
|
'description' => $data['description'] ?? '',
|
||||||
|
'defaultBranch' => $data['default_branch'],
|
||||||
|
'url' => $data['html_url'],
|
||||||
|
'language' => $data['language'] ?? '',
|
||||||
|
'stars' => $data['stars_count'] ?? 0,
|
||||||
|
'forks' => $data['forks_count'] ?? 0,
|
||||||
|
'private' => $data['private'] ?? false
|
||||||
|
];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
throw new \RuntimeException('Failed to fetch repository info from Gitea: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to Gitea instance
|
||||||
|
*
|
||||||
|
* @param string $baseUrl Gitea instance URL
|
||||||
|
* @return bool True if connection successful
|
||||||
|
*/
|
||||||
|
public function testConnection(string $baseUrl): bool
|
||||||
|
{
|
||||||
|
$url = sprintf('%s/api/v1/version', rtrim($baseUrl, '/'));
|
||||||
|
|
||||||
|
$options = [];
|
||||||
|
if ($this->giteaToken) {
|
||||||
|
$options['headers'] = [
|
||||||
|
'Authorization' => 'token ' . $this->giteaToken
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->httpClient->request('GET', $url, $options);
|
||||||
|
return $response->getStatusCode() === 200;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
const Encore = require('@symfony/webpack-encore');
|
const Encore = require('@symfony/webpack-encore');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
// Manually configure the runtime environment if not already configured yet by the "encore" command.
|
// Manually configure the runtime environment if not already configured yet by the "encore" command.
|
||||||
// It's useful when you use tools that rely on webpack.config.js file.
|
// It's useful when you use tools that rely on webpack.config.js file.
|
||||||
@ -73,6 +74,12 @@ Encore
|
|||||||
|
|
||||||
// Enable PostCSS loader for Vue SFCs
|
// Enable PostCSS loader for Vue SFCs
|
||||||
.enablePostCssLoader()
|
.enablePostCssLoader()
|
||||||
|
|
||||||
|
// Alias für '@/images' und '@' auf 'assets' setzen
|
||||||
|
.addAliases({
|
||||||
|
'@': path.resolve(__dirname, 'assets/js'),
|
||||||
|
'@images': path.resolve(__dirname, 'assets/images')
|
||||||
|
})
|
||||||
;
|
;
|
||||||
|
|
||||||
module.exports = Encore.getWebpackConfig();
|
module.exports = Encore.getWebpackConfig();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user