github-issue-tower-defence-management 1.104.4 → 1.105.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +8 -7
- package/bin/adapter/entry-points/cli/index.js +21 -13
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/console/{consoleServer.js → webServer.js} +11 -11
- package/bin/adapter/entry-points/console/webServer.js.map +1 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +41 -2
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/dashboardRowWriter.js +34 -0
- package/bin/adapter/entry-points/handlers/dashboardRowWriter.js.map +1 -0
- package/bin/adapter/entry-points/handlers/machineStatusWriter.js +60 -0
- package/bin/adapter/entry-points/handlers/machineStatusWriter.js.map +1 -0
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js +98 -0
- package/bin/adapter/entry-points/handlers/tokenStatusWriter.js.map +1 -0
- package/bin/adapter/proxy/RateLimitCache.js +3 -0
- package/bin/adapter/proxy/RateLimitCache.js.map +1 -1
- package/bin/adapter/repositories/ProcHostMetricsRepository.js +136 -0
- package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -0
- package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js +131 -0
- package/bin/adapter/repositories/ProcTakeOwnershipSpawnRepository.js.map +1 -0
- package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js +3 -0
- package/bin/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.js.map +1 -0
- package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js +38 -0
- package/bin/domain/usecases/dashboard/GenerateDashboardRowUseCase.js.map +1 -0
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js +115 -0
- package/bin/domain/usecases/dashboard/GenerateTokenStatusUseCase.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +74 -21
- package/src/adapter/entry-points/cli/index.ts +146 -136
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +2 -2
- package/src/adapter/entry-points/console/{consoleServer.test.ts → webServer.test.ts} +32 -32
- package/src/adapter/entry-points/console/{consoleServer.ts → webServer.ts} +15 -17
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +52 -0
- package/src/adapter/entry-points/handlers/dashboardRowWriter.test.ts +111 -0
- package/src/adapter/entry-points/handlers/dashboardRowWriter.ts +51 -0
- package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +100 -0
- package/src/adapter/entry-points/handlers/machineStatusWriter.ts +79 -0
- package/src/adapter/entry-points/handlers/tokenStatusWriter.test.ts +176 -0
- package/src/adapter/entry-points/handlers/tokenStatusWriter.ts +139 -0
- package/src/adapter/proxy/RateLimitCache.test.ts +32 -0
- package/src/adapter/proxy/RateLimitCache.ts +6 -0
- package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +135 -0
- package/src/adapter/repositories/ProcHostMetricsRepository.ts +136 -0
- package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.test.ts +130 -0
- package/src/adapter/repositories/ProcTakeOwnershipSpawnRepository.ts +118 -0
- package/src/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.ts +8 -0
- package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.test.ts +159 -0
- package/src/domain/usecases/dashboard/GenerateDashboardRowUseCase.ts +72 -0
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.test.ts +209 -0
- package/src/domain/usecases/dashboard/GenerateTokenStatusUseCase.ts +195 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/console/{consoleServer.d.ts → webServer.d.ts} +7 -7
- package/types/adapter/entry-points/console/webServer.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts +15 -0
- package/types/adapter/entry-points/handlers/dashboardRowWriter.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +16 -0
- package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -0
- package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts +21 -0
- package/types/adapter/entry-points/handlers/tokenStatusWriter.d.ts.map +1 -0
- package/types/adapter/proxy/RateLimitCache.d.ts +2 -0
- package/types/adapter/proxy/RateLimitCache.d.ts.map +1 -1
- package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +23 -0
- package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -0
- package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts +11 -0
- package/types/adapter/repositories/ProcTakeOwnershipSpawnRepository.d.ts.map +1 -0
- package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts +8 -0
- package/types/domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository.d.ts.map +1 -0
- package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts +19 -0
- package/types/domain/usecases/dashboard/GenerateDashboardRowUseCase.d.ts.map +1 -0
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts +53 -0
- package/types/domain/usecases/dashboard/GenerateTokenStatusUseCase.d.ts.map +1 -0
- package/bin/adapter/entry-points/console/consoleServer.js.map +0 -1
- package/types/adapter/entry-points/console/consoleServer.d.ts.map +0 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
export type CpuSample = {
|
|
5
|
+
total: number;
|
|
6
|
+
idle: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type LoadAverages = {
|
|
10
|
+
oneMinute: number;
|
|
11
|
+
fiveMinute: number;
|
|
12
|
+
fifteenMinute: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const DEFAULT_PROC_DIRECTORY = '/proc';
|
|
16
|
+
const CPU_SAMPLE_INTERVAL_MS = 400;
|
|
17
|
+
|
|
18
|
+
export const parseMemoryUsedPercent = (meminfoText: string): number => {
|
|
19
|
+
const fields = new Map<string, number>();
|
|
20
|
+
for (const line of meminfoText.split('\n')) {
|
|
21
|
+
const separatorIndex = line.indexOf(':');
|
|
22
|
+
if (separatorIndex <= 0) {
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
26
|
+
const valuePart = line
|
|
27
|
+
.slice(separatorIndex + 1)
|
|
28
|
+
.trim()
|
|
29
|
+
.split(/\s+/)[0];
|
|
30
|
+
const value = Number(valuePart);
|
|
31
|
+
if (Number.isFinite(value)) {
|
|
32
|
+
fields.set(key, value);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const total = fields.get('MemTotal');
|
|
36
|
+
const available = fields.get('MemAvailable');
|
|
37
|
+
if (total === undefined || available === undefined) {
|
|
38
|
+
throw new Error('MemTotal and MemAvailable are required');
|
|
39
|
+
}
|
|
40
|
+
if (total <= 0) {
|
|
41
|
+
throw new Error('MemTotal must be positive');
|
|
42
|
+
}
|
|
43
|
+
const used = total - available;
|
|
44
|
+
return Math.round((used / total) * 100);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const parseCpuSample = (statText: string): CpuSample => {
|
|
48
|
+
for (const line of statText.split('\n')) {
|
|
49
|
+
if (line.startsWith('cpu ')) {
|
|
50
|
+
const values = line
|
|
51
|
+
.trim()
|
|
52
|
+
.split(/\s+/)
|
|
53
|
+
.slice(1)
|
|
54
|
+
.map((value) => Number(value));
|
|
55
|
+
if (values.some((value) => !Number.isFinite(value))) {
|
|
56
|
+
throw new Error('aggregate cpu line contains non-numeric values');
|
|
57
|
+
}
|
|
58
|
+
const idle = values[3] + (values.length > 4 ? values[4] : 0);
|
|
59
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
60
|
+
return { total, idle };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
throw new Error('aggregate cpu line not found');
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const cpuUsedPercentFromSamples = (
|
|
67
|
+
first: CpuSample,
|
|
68
|
+
second: CpuSample,
|
|
69
|
+
): number => {
|
|
70
|
+
const totalDelta = second.total - first.total;
|
|
71
|
+
const idleDelta = second.idle - first.idle;
|
|
72
|
+
if (totalDelta <= 0) {
|
|
73
|
+
throw new Error('total delta must be positive');
|
|
74
|
+
}
|
|
75
|
+
const busyDelta = totalDelta - idleDelta;
|
|
76
|
+
return Math.round((busyDelta / totalDelta) * 100);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const parseLoadAverages = (loadavgText: string): LoadAverages => {
|
|
80
|
+
const parts = loadavgText.trim().split(/\s+/);
|
|
81
|
+
const oneMinute = Number(parts[0]);
|
|
82
|
+
const fiveMinute = Number(parts[1]);
|
|
83
|
+
const fifteenMinute = Number(parts[2]);
|
|
84
|
+
if (
|
|
85
|
+
!Number.isFinite(oneMinute) ||
|
|
86
|
+
!Number.isFinite(fiveMinute) ||
|
|
87
|
+
!Number.isFinite(fifteenMinute)
|
|
88
|
+
) {
|
|
89
|
+
throw new Error('loadavg must contain three numeric values');
|
|
90
|
+
}
|
|
91
|
+
return { oneMinute, fiveMinute, fifteenMinute };
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const cycleMinutesFromMtimes = (
|
|
95
|
+
mtimesDescendingSeconds: number[],
|
|
96
|
+
): number | null => {
|
|
97
|
+
if (mtimesDescendingSeconds.length < 2) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
return Math.round(
|
|
101
|
+
(mtimesDescendingSeconds[0] - mtimesDescendingSeconds[1]) / 60,
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export class ProcHostMetricsRepository {
|
|
106
|
+
constructor(
|
|
107
|
+
private readonly procDirectory: string = DEFAULT_PROC_DIRECTORY,
|
|
108
|
+
private readonly sleep: (milliseconds: number) => Promise<void> = (
|
|
109
|
+
milliseconds,
|
|
110
|
+
) =>
|
|
111
|
+
new Promise((resolve) => {
|
|
112
|
+
setTimeout(resolve, milliseconds);
|
|
113
|
+
}),
|
|
114
|
+
) {}
|
|
115
|
+
|
|
116
|
+
readMemoryUsedPercent = (): number =>
|
|
117
|
+
parseMemoryUsedPercent(
|
|
118
|
+
fs.readFileSync(path.join(this.procDirectory, 'meminfo'), 'utf8'),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
readCpuUsedPercent = async (): Promise<number> => {
|
|
122
|
+
const first = parseCpuSample(
|
|
123
|
+
fs.readFileSync(path.join(this.procDirectory, 'stat'), 'utf8'),
|
|
124
|
+
);
|
|
125
|
+
await this.sleep(CPU_SAMPLE_INTERVAL_MS);
|
|
126
|
+
const second = parseCpuSample(
|
|
127
|
+
fs.readFileSync(path.join(this.procDirectory, 'stat'), 'utf8'),
|
|
128
|
+
);
|
|
129
|
+
return cpuUsedPercentFromSamples(first, second);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
readLoadAverages = (): LoadAverages =>
|
|
133
|
+
parseLoadAverages(
|
|
134
|
+
fs.readFileSync(path.join(this.procDirectory, 'loadavg'), 'utf8'),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { ProcTakeOwnershipSpawnRepository } from './ProcTakeOwnershipSpawnRepository';
|
|
5
|
+
|
|
6
|
+
type FakeProcess = {
|
|
7
|
+
pid: number;
|
|
8
|
+
cmdline: string;
|
|
9
|
+
environ: Record<string, string>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const issueUrl = 'https://github.com/HiromiShikata/example/issues/1';
|
|
13
|
+
const argv = (...parts: string[]): string => `${parts.join('\0')}\0`;
|
|
14
|
+
|
|
15
|
+
describe('ProcTakeOwnershipSpawnRepository', () => {
|
|
16
|
+
let procDirectory: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
procDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-proc-spawn-'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
fs.rmSync(procDirectory, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const writeProcess = (fakeProcess: FakeProcess): void => {
|
|
27
|
+
const processDirectory = path.join(procDirectory, String(fakeProcess.pid));
|
|
28
|
+
fs.mkdirSync(processDirectory, { recursive: true });
|
|
29
|
+
fs.writeFileSync(
|
|
30
|
+
path.join(processDirectory, 'cmdline'),
|
|
31
|
+
fakeProcess.cmdline,
|
|
32
|
+
);
|
|
33
|
+
const environBuffer = Object.entries(fakeProcess.environ)
|
|
34
|
+
.map(([key, value]) => `${key}=${value}\0`)
|
|
35
|
+
.join('');
|
|
36
|
+
fs.writeFileSync(path.join(processDirectory, 'environ'), environBuffer);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
it('reads the token and log path of a Take ownership spawn', () => {
|
|
40
|
+
writeProcess({
|
|
41
|
+
pid: 301,
|
|
42
|
+
cmdline: argv(
|
|
43
|
+
'bash',
|
|
44
|
+
'-c',
|
|
45
|
+
`claude-agent -p "Take ownership of ${issueUrl}" | tee /home/user/logs-aw/20260626_120000_abc.log`,
|
|
46
|
+
),
|
|
47
|
+
environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-a' },
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
|
|
51
|
+
|
|
52
|
+
expect(repository.listSpawns()).toEqual([
|
|
53
|
+
{ token: 'token-a', logPath: '/logs-aw/20260626_120000_abc.log' },
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('ignores a process that is not a Take ownership spawn', () => {
|
|
58
|
+
writeProcess({
|
|
59
|
+
pid: 302,
|
|
60
|
+
cmdline: argv('claude', '--name', issueUrl),
|
|
61
|
+
environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-b' },
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
|
|
65
|
+
|
|
66
|
+
expect(repository.listSpawns()).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('ignores a Take ownership process whose cmdline carries no log path', () => {
|
|
70
|
+
writeProcess({
|
|
71
|
+
pid: 303,
|
|
72
|
+
cmdline: argv('claude-agent', '-p', `Take ownership of ${issueUrl}`),
|
|
73
|
+
environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-c' },
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
|
|
77
|
+
|
|
78
|
+
expect(repository.listSpawns()).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('ignores a Take ownership process without an oauth token', () => {
|
|
82
|
+
writeProcess({
|
|
83
|
+
pid: 304,
|
|
84
|
+
cmdline: argv(
|
|
85
|
+
'bash',
|
|
86
|
+
'-c',
|
|
87
|
+
`Take ownership of ${issueUrl} | tee /home/user/logs-aw/x.log`,
|
|
88
|
+
),
|
|
89
|
+
environ: { ANTHROPIC_API_KEY: 'api-key' },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
|
|
93
|
+
|
|
94
|
+
expect(repository.listSpawns()).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('returns one entry per child process so the use case can dedupe by log path', () => {
|
|
98
|
+
const fullLogPath = '/home/user/logs-aw/20260626_120000_dup.log';
|
|
99
|
+
const capturedLogPath = '/logs-aw/20260626_120000_dup.log';
|
|
100
|
+
writeProcess({
|
|
101
|
+
pid: 305,
|
|
102
|
+
cmdline: argv(
|
|
103
|
+
'bash',
|
|
104
|
+
'-c',
|
|
105
|
+
`Take ownership of ${issueUrl} | tee ${fullLogPath}`,
|
|
106
|
+
),
|
|
107
|
+
environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-d' },
|
|
108
|
+
});
|
|
109
|
+
writeProcess({
|
|
110
|
+
pid: 306,
|
|
111
|
+
cmdline: argv('tee', fullLogPath, 'Take ownership marker'),
|
|
112
|
+
environ: { CLAUDE_CODE_OAUTH_TOKEN: 'token-d' },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const repository = new ProcTakeOwnershipSpawnRepository(procDirectory);
|
|
116
|
+
|
|
117
|
+
expect(repository.listSpawns()).toEqual([
|
|
118
|
+
{ token: 'token-d', logPath: capturedLogPath },
|
|
119
|
+
{ token: 'token-d', logPath: capturedLogPath },
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('returns an empty list when the proc directory does not exist', () => {
|
|
124
|
+
const repository = new ProcTakeOwnershipSpawnRepository(
|
|
125
|
+
path.join(procDirectory, 'missing'),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(repository.listSpawns()).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import {
|
|
4
|
+
TakeOwnershipSpawn,
|
|
5
|
+
TakeOwnershipSpawnRepository,
|
|
6
|
+
} from '../../domain/usecases/adapter-interfaces/TakeOwnershipSpawnRepository';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_PROC_DIRECTORY = '/proc';
|
|
9
|
+
const OAUTH_TOKEN_ENVIRON_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
|
|
10
|
+
const TAKE_OWNERSHIP_MARKER = 'Take ownership';
|
|
11
|
+
const LOG_PATH_PATTERN = /\/logs-aw\/[^\0"]+\.log/;
|
|
12
|
+
|
|
13
|
+
const parseCommandLineArguments = (cmdline: string): string[] =>
|
|
14
|
+
cmdline.split('\0').filter((argument) => argument.length > 0);
|
|
15
|
+
|
|
16
|
+
const isTakeOwnershipSpawn = (commandArguments: string[]): boolean =>
|
|
17
|
+
commandArguments.some((argument) => argument.includes(TAKE_OWNERSHIP_MARKER));
|
|
18
|
+
|
|
19
|
+
const extractLogPath = (cmdline: string): string | null => {
|
|
20
|
+
const match = cmdline.match(LOG_PATH_PATTERN);
|
|
21
|
+
return match === null ? null : match[0];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export class ProcTakeOwnershipSpawnRepository implements TakeOwnershipSpawnRepository {
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly procDirectory: string = DEFAULT_PROC_DIRECTORY,
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
listSpawns = (): TakeOwnershipSpawn[] => {
|
|
30
|
+
const spawns: TakeOwnershipSpawn[] = [];
|
|
31
|
+
for (const pidDirectory of this.listProcessIdDirectories()) {
|
|
32
|
+
const spawn = this.readSpawn(pidDirectory);
|
|
33
|
+
if (spawn !== null) {
|
|
34
|
+
spawns.push(spawn);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return spawns;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
private listProcessIdDirectories = (): string[] => {
|
|
41
|
+
let entries: string[];
|
|
42
|
+
try {
|
|
43
|
+
entries = fs.readdirSync(this.procDirectory);
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
return entries.filter((entry) => /^\d+$/.test(entry));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
private readSpawn = (
|
|
51
|
+
processIdDirectory: string,
|
|
52
|
+
): TakeOwnershipSpawn | null => {
|
|
53
|
+
const rawCmdline = this.readRawCmdline(processIdDirectory);
|
|
54
|
+
if (rawCmdline === null) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const commandArguments = parseCommandLineArguments(rawCmdline);
|
|
58
|
+
if (!isTakeOwnershipSpawn(commandArguments)) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const logPath = extractLogPath(rawCmdline);
|
|
62
|
+
if (logPath === null) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
const environ = this.readEnviron(processIdDirectory);
|
|
66
|
+
if (environ === null) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const token = environ.get(OAUTH_TOKEN_ENVIRON_KEY);
|
|
70
|
+
if (token === undefined || token.length === 0) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return { token, logPath };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
private readRawCmdline = (processIdDirectory: string): string | null => {
|
|
77
|
+
const cmdlinePath = path.join(
|
|
78
|
+
this.procDirectory,
|
|
79
|
+
processIdDirectory,
|
|
80
|
+
'cmdline',
|
|
81
|
+
);
|
|
82
|
+
try {
|
|
83
|
+
return fs.readFileSync(cmdlinePath, 'utf8');
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
private readEnviron = (
|
|
90
|
+
processIdDirectory: string,
|
|
91
|
+
): Map<string, string> | null => {
|
|
92
|
+
const environPath = path.join(
|
|
93
|
+
this.procDirectory,
|
|
94
|
+
processIdDirectory,
|
|
95
|
+
'environ',
|
|
96
|
+
);
|
|
97
|
+
let raw: string;
|
|
98
|
+
try {
|
|
99
|
+
raw = fs.readFileSync(environPath, 'utf8');
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const environ = new Map<string, string>();
|
|
104
|
+
for (const entry of raw.split('\0')) {
|
|
105
|
+
if (entry.length === 0) {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
const separatorIndex = entry.indexOf('=');
|
|
109
|
+
if (separatorIndex <= 0) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const key = entry.slice(0, separatorIndex);
|
|
113
|
+
const value = entry.slice(separatorIndex + 1);
|
|
114
|
+
environ.set(key, value);
|
|
115
|
+
}
|
|
116
|
+
return environ;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { Issue } from '../../entities/Issue';
|
|
2
|
+
import { GenerateDashboardRowUseCase } from './GenerateDashboardRowUseCase';
|
|
3
|
+
|
|
4
|
+
const ASSIGNEE = 'HiromiShikata';
|
|
5
|
+
|
|
6
|
+
let issueCounter = 0;
|
|
7
|
+
const makeIssue = (overrides: Partial<Issue>): Issue => {
|
|
8
|
+
issueCounter += 1;
|
|
9
|
+
return {
|
|
10
|
+
nameWithOwner: 'demo/repo',
|
|
11
|
+
number: issueCounter,
|
|
12
|
+
title: `Issue ${issueCounter}`,
|
|
13
|
+
state: 'OPEN',
|
|
14
|
+
status: null,
|
|
15
|
+
story: null,
|
|
16
|
+
nextActionDate: null,
|
|
17
|
+
nextActionHour: null,
|
|
18
|
+
estimationMinutes: null,
|
|
19
|
+
dependedIssueUrls: [],
|
|
20
|
+
completionDate50PercentConfidence: null,
|
|
21
|
+
url: `https://github.com/demo/repo/issues/${issueCounter}`,
|
|
22
|
+
assignees: [ASSIGNEE],
|
|
23
|
+
labels: [],
|
|
24
|
+
org: 'demo',
|
|
25
|
+
repo: 'repo',
|
|
26
|
+
body: '',
|
|
27
|
+
itemId: `item-${issueCounter}`,
|
|
28
|
+
isPr: false,
|
|
29
|
+
isInProgress: false,
|
|
30
|
+
isClosed: false,
|
|
31
|
+
createdAt: new Date('2026-06-13T08:18:45.000Z'),
|
|
32
|
+
author: 'someone',
|
|
33
|
+
closingIssueReferenceUrls: [],
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe('GenerateDashboardRowUseCase', () => {
|
|
39
|
+
const usecase = new GenerateDashboardRowUseCase();
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
issueCounter = 0;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('returns all-zero counts for an empty issue list', () => {
|
|
46
|
+
expect(usecase.run({ issues: [], assigneeLogin: ASSIGNEE })).toEqual({
|
|
47
|
+
unread: 0,
|
|
48
|
+
todo: 0,
|
|
49
|
+
qc: 0,
|
|
50
|
+
fail: 0,
|
|
51
|
+
pr: 0,
|
|
52
|
+
ws: 0,
|
|
53
|
+
dep: 0,
|
|
54
|
+
blocker: 0,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('counts actionable Unread, Todo, Awaiting Quality Check and Awaiting Workspace issues', () => {
|
|
59
|
+
const issues = [
|
|
60
|
+
makeIssue({ status: 'Unread' }),
|
|
61
|
+
makeIssue({ status: 'Todo by human' }),
|
|
62
|
+
makeIssue({ status: 'Awaiting Quality Check' }),
|
|
63
|
+
makeIssue({ status: 'Awaiting Workspace' }),
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
expect(usecase.run({ issues, assigneeLogin: ASSIGNEE })).toEqual({
|
|
67
|
+
unread: 1,
|
|
68
|
+
todo: 1,
|
|
69
|
+
qc: 1,
|
|
70
|
+
fail: 0,
|
|
71
|
+
pr: 0,
|
|
72
|
+
ws: 1,
|
|
73
|
+
dep: 0,
|
|
74
|
+
blocker: 0,
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('excludes non-actionable issues from actionable status columns', () => {
|
|
79
|
+
const issues = [
|
|
80
|
+
makeIssue({ status: 'Unread', nextActionDate: new Date() }),
|
|
81
|
+
makeIssue({ status: 'Unread', nextActionHour: 9 }),
|
|
82
|
+
makeIssue({
|
|
83
|
+
status: 'Awaiting Quality Check',
|
|
84
|
+
dependedIssueUrls: ['https://github.com/demo/repo/issues/999'],
|
|
85
|
+
}),
|
|
86
|
+
makeIssue({ status: 'Todo by human', assignees: ['someone-else'] }),
|
|
87
|
+
makeIssue({ status: 'Awaiting Workspace', isClosed: true }),
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
expect(usecase.run({ issues, assigneeLogin: ASSIGNEE })).toEqual({
|
|
91
|
+
unread: 0,
|
|
92
|
+
todo: 0,
|
|
93
|
+
qc: 0,
|
|
94
|
+
fail: 0,
|
|
95
|
+
pr: 0,
|
|
96
|
+
ws: 0,
|
|
97
|
+
dep: 0,
|
|
98
|
+
blocker: 0,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('counts Preparation and Failed Preparation by whole status without the actionable predicate', () => {
|
|
103
|
+
const issues = [
|
|
104
|
+
makeIssue({ status: 'Preparation', nextActionHour: 9 }),
|
|
105
|
+
makeIssue({
|
|
106
|
+
status: 'Failed Preparation',
|
|
107
|
+
dependedIssueUrls: ['https://github.com/demo/repo/issues/999'],
|
|
108
|
+
}),
|
|
109
|
+
makeIssue({ status: 'Preparation', isClosed: true }),
|
|
110
|
+
makeIssue({ status: 'Failed Preparation', assignees: ['someone-else'] }),
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
expect(usecase.run({ issues, assigneeLogin: ASSIGNEE })).toEqual({
|
|
114
|
+
unread: 0,
|
|
115
|
+
todo: 0,
|
|
116
|
+
qc: 0,
|
|
117
|
+
fail: 1,
|
|
118
|
+
pr: 1,
|
|
119
|
+
ws: 0,
|
|
120
|
+
dep: 0,
|
|
121
|
+
blocker: 0,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('counts dep as Awaiting Workspace issues blocked by a dependency, and never as ws', () => {
|
|
126
|
+
const issues = [
|
|
127
|
+
makeIssue({ status: 'Awaiting Workspace' }),
|
|
128
|
+
makeIssue({
|
|
129
|
+
status: 'Awaiting Workspace',
|
|
130
|
+
dependedIssueUrls: ['https://github.com/demo/repo/issues/999'],
|
|
131
|
+
}),
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
expect(usecase.run({ issues, assigneeLogin: ASSIGNEE })).toMatchObject({
|
|
135
|
+
ws: 1,
|
|
136
|
+
dep: 1,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('counts blocker by case-insensitive workflow blocker story membership for non-closed mine issues', () => {
|
|
141
|
+
const issues = [
|
|
142
|
+
makeIssue({ status: 'Unread', story: 'Workflow Blocker / urgent' }),
|
|
143
|
+
makeIssue({ status: 'Awaiting Workspace', story: 'workflow blocker' }),
|
|
144
|
+
makeIssue({ status: 'Unread', story: 'regular / maintenance' }),
|
|
145
|
+
makeIssue({
|
|
146
|
+
status: 'Unread',
|
|
147
|
+
story: 'workflow blocker',
|
|
148
|
+
isClosed: true,
|
|
149
|
+
}),
|
|
150
|
+
makeIssue({
|
|
151
|
+
status: 'Unread',
|
|
152
|
+
story: 'workflow blocker',
|
|
153
|
+
assignees: ['someone-else'],
|
|
154
|
+
}),
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
expect(usecase.run({ issues, assigneeLogin: ASSIGNEE }).blocker).toBe(2);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Issue } from '../../entities/Issue';
|
|
2
|
+
import {
|
|
3
|
+
AWAITING_QUALITY_CHECK_STATUS_NAME,
|
|
4
|
+
AWAITING_WORKSPACE_STATUS_NAME,
|
|
5
|
+
DEFAULT_STATUS_NAME,
|
|
6
|
+
FAILED_PREPARATION_STATUS_NAME,
|
|
7
|
+
PREPARATION_STATUS_NAME,
|
|
8
|
+
TODO_STATUS_NAME,
|
|
9
|
+
} from '../../entities/WorkflowStatus';
|
|
10
|
+
|
|
11
|
+
export type DashboardRow = {
|
|
12
|
+
unread: number;
|
|
13
|
+
todo: number;
|
|
14
|
+
qc: number;
|
|
15
|
+
fail: number;
|
|
16
|
+
pr: number;
|
|
17
|
+
ws: number;
|
|
18
|
+
dep: number;
|
|
19
|
+
blocker: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type GenerateDashboardRowInput = {
|
|
23
|
+
issues: Issue[];
|
|
24
|
+
assigneeLogin: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const WORKFLOW_BLOCKER_STORY_MARKER = 'workflow blocker';
|
|
28
|
+
|
|
29
|
+
export class GenerateDashboardRowUseCase {
|
|
30
|
+
run = (input: GenerateDashboardRowInput): DashboardRow => {
|
|
31
|
+
const { issues, assigneeLogin } = input;
|
|
32
|
+
|
|
33
|
+
const mine = (issue: Issue): boolean =>
|
|
34
|
+
issue.isClosed === false && issue.assignees.includes(assigneeLogin);
|
|
35
|
+
|
|
36
|
+
const actionable = (issue: Issue): boolean =>
|
|
37
|
+
mine(issue) &&
|
|
38
|
+
issue.dependedIssueUrls.length === 0 &&
|
|
39
|
+
issue.nextActionDate === null &&
|
|
40
|
+
issue.nextActionHour === null;
|
|
41
|
+
|
|
42
|
+
const countActionableWithStatus = (statusName: string): number =>
|
|
43
|
+
issues.filter((issue) => issue.status === statusName && actionable(issue))
|
|
44
|
+
.length;
|
|
45
|
+
|
|
46
|
+
const countMineWithStatus = (statusName: string): number =>
|
|
47
|
+
issues.filter((issue) => mine(issue) && issue.status === statusName)
|
|
48
|
+
.length;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
unread: countActionableWithStatus(DEFAULT_STATUS_NAME),
|
|
52
|
+
todo: countActionableWithStatus(TODO_STATUS_NAME),
|
|
53
|
+
qc: countActionableWithStatus(AWAITING_QUALITY_CHECK_STATUS_NAME),
|
|
54
|
+
fail: countMineWithStatus(FAILED_PREPARATION_STATUS_NAME),
|
|
55
|
+
pr: countMineWithStatus(PREPARATION_STATUS_NAME),
|
|
56
|
+
ws: countActionableWithStatus(AWAITING_WORKSPACE_STATUS_NAME),
|
|
57
|
+
dep: issues.filter(
|
|
58
|
+
(issue) =>
|
|
59
|
+
mine(issue) &&
|
|
60
|
+
issue.status === AWAITING_WORKSPACE_STATUS_NAME &&
|
|
61
|
+
issue.dependedIssueUrls.length > 0,
|
|
62
|
+
).length,
|
|
63
|
+
blocker: issues.filter(
|
|
64
|
+
(issue) =>
|
|
65
|
+
mine(issue) &&
|
|
66
|
+
(issue.story ?? '')
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.includes(WORKFLOW_BLOCKER_STORY_MARKER),
|
|
69
|
+
).length,
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
}
|