Skip to content

Commit ef09a05

Browse files
Merge pull request #46 from codecov/spalmurray/coverage
feat: Add line coverage
2 parents b1c4139 + 32253b1 commit ef09a05

14 files changed

+312
-19
lines changed

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@
33
"displayName": "Codecov YAML Validator",
44
"publisher": "codecov",
55
"activationEvents": [
6-
"onInstall",
7-
"onUpdate"
6+
"onStartupFinished"
87
],
98
"icon": "./icons/codecov.png",
109
"description": "Codecov's official validator extension for Visual Studio Code, it helps with setup and configuration of new repositories.",
@@ -56,14 +55,14 @@
5655
"codecov.yaml",
5756
"codecov.yml"
5857
],
59-
"configuration": "./language-configuration.json"
58+
"configuration": "./src/yaml/language-configuration.json"
6059
}
6160
],
6261
"grammars": [
6362
{
6463
"language": "codecov",
6564
"scopeName": "source.codecov",
66-
"path": "./syntaxes/codecov.tmLanguage.json"
65+
"path": "./src/yaml/syntaxes/codecov.tmLanguage.json"
6766
}
6867
],
6968
"menus": {
@@ -83,6 +82,14 @@
8382
"light": "./icons/codecov-light.png",
8483
"dark": "./icons/codecov-dark.png"
8584
}
85+
},
86+
{
87+
"command": "codecov.reset.api.key",
88+
"title": "Codecov: Reset the saved API key",
89+
"icon": {
90+
"light": "./icons/codecov-light.png",
91+
"dark": "./icons/codecov-dark.png"
92+
}
8693
}
8794
],
8895
"configurationDefaults": {
@@ -96,13 +103,31 @@
96103
},
97104
"editor.autoIndent": "keep"
98105
}
99-
}
100-
},
101-
"configuration": {
102-
"yaml.keyOrdering": {
103-
"type": "boolean",
104-
"default": false,
105-
"description": "Enforces alphabetical ordering of keys in mappings when set to true"
106+
},
107+
"configuration": {
108+
"title": "Codecov",
109+
"properties": {
110+
"codecov.coverage.enabled": {
111+
"type": "boolean",
112+
"default": false,
113+
"description": "Toggle Codecov line coverage decorations.",
114+
"order": 0
115+
},
116+
"codecov.api.gitProvider": {
117+
"type": "string",
118+
"default": "github",
119+
"enum": ["github", "github_enterprise", "gitlab", "gitlab_enterprise", "bitbucket", "bitbucket_server"],
120+
"description": "Where are your repositories hosted?",
121+
"order": 1
122+
},
123+
"codecov.api.url": {
124+
"type": "string",
125+
"default": "https://api.codecov.io",
126+
"format": "url",
127+
"description": "If you're self-hosting Codecov, update this to point to your self-hosted API.",
128+
"order": 2
129+
}
130+
}
106131
}
107132
},
108133
"scripts": {

src/coverage/coverage.ts

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
// Future goals:
2+
// - Persist owner/repo names in workspace storage
3+
// - Cache coverage
4+
// - Show coverage totals somewhere
5+
// - Maybe add codecov button to disable and/or view in codecov
6+
7+
import {
8+
ExtensionContext,
9+
OverviewRulerLane,
10+
Position,
11+
Range,
12+
Uri,
13+
commands,
14+
window,
15+
workspace,
16+
} from "vscode";
17+
import axios from "axios";
18+
19+
type Coverage =
20+
| {
21+
line_coverage: [number, number][];
22+
}
23+
| undefined;
24+
25+
const Colors = {
26+
covered: "rgb(33,181,119)",
27+
partial: "rgb(244,176,27)",
28+
missed: "rgb(245,32,32)",
29+
} as const;
30+
31+
const Icons = {
32+
covered: Uri.parse(
33+
"data:image/svg+xml;base64," +
34+
Buffer.from(
35+
`<svg version="1.1" width="18" height="18" xmlns="http://www.w3.org/2000/svg">
36+
<rect width="3" height="100%" fill="${Colors.covered}" />
37+
</svg>`
38+
).toString("base64")
39+
),
40+
partial: Uri.parse(
41+
"data:image/svg+xml;base64," +
42+
Buffer.from(
43+
`<svg version="1.1" width="18" height="18" xmlns="http://www.w3.org/2000/svg">
44+
<rect width="3" height="100%" fill="${Colors.partial}" />
45+
</svg>`
46+
).toString("base64")
47+
),
48+
missed: Uri.parse(
49+
"data:image/svg+xml;base64," +
50+
Buffer.from(
51+
`<svg version="1.1" width="18" height="18" xmlns="http://www.w3.org/2000/svg">
52+
<rect width="3" height="100%" fill="${Colors.missed}" />
53+
</svg>`
54+
).toString("base64")
55+
),
56+
} as const;
57+
58+
export function activateCoverage(context: ExtensionContext) {
59+
const command = "codecov.reset.api.key";
60+
61+
const resetApiKeyHandler = () => {
62+
context.secrets.delete("api.key");
63+
updateDecorations();
64+
};
65+
66+
context.subscriptions.push(
67+
commands.registerCommand(command, resetApiKeyHandler)
68+
);
69+
70+
const lineCoveredDecoration = window.createTextEditorDecorationType({
71+
gutterIconPath: Icons.covered,
72+
overviewRulerColor: Colors.covered,
73+
overviewRulerLane: OverviewRulerLane.Right,
74+
});
75+
const linePartialDecoration = window.createTextEditorDecorationType({
76+
gutterIconPath: Icons.partial,
77+
overviewRulerColor: Colors.partial,
78+
overviewRulerLane: OverviewRulerLane.Right,
79+
});
80+
const lineMissedDecoration = window.createTextEditorDecorationType({
81+
gutterIconPath: Icons.missed,
82+
overviewRulerColor: Colors.missed,
83+
overviewRulerLane: OverviewRulerLane.Right,
84+
});
85+
86+
let activeEditor = window.activeTextEditor;
87+
88+
async function updateDecorations() {
89+
if (!activeEditor) {
90+
return;
91+
}
92+
93+
const config = workspace.getConfiguration("codecov");
94+
95+
const enabled = config.get("coverage.enabled");
96+
if (!enabled) return;
97+
98+
let apiKey = await context.secrets.get("api.key");
99+
let apiUrl = config.get("api.url");
100+
const provider = config.get("api.gitProvider");
101+
102+
if (!apiKey) {
103+
const result = await window.showErrorMessage(
104+
"To see Codecov line coverage in your editor, you must first set an API Key.",
105+
{
106+
modal: true,
107+
detail:
108+
"If you don't want to do this right now, you can disable Codecov line coverage in the Codecov extension's settings.",
109+
},
110+
"Set an API Key"
111+
);
112+
113+
if (result === "Set an API Key") {
114+
apiKey = await window.showInputBox({
115+
title: "Enter your Codecov API Key",
116+
prompt:
117+
"You can generate an API key in your account settings within the Codecov app.",
118+
});
119+
if (apiKey) await context.secrets.store("api.key", apiKey);
120+
updateDecorations();
121+
return;
122+
}
123+
}
124+
125+
if (!apiUrl) {
126+
// Just reset it to default for this workspace
127+
await config.update("api.url", "https://api.codecov.io", false);
128+
apiUrl = "https://api.codecov.io";
129+
}
130+
131+
const path = encodeURIComponent(
132+
workspace.asRelativePath(activeEditor.document.fileName)
133+
);
134+
135+
const pathToWorkspace = workspace.getWorkspaceFolder(
136+
Uri.file(activeEditor.document.fileName)
137+
)?.uri.path;
138+
139+
const gitConfig = Uri.file(`${pathToWorkspace}/.git/config`);
140+
const remote = await workspace.fs
141+
.readFile(gitConfig)
142+
.then((buf) => buf.toString())
143+
.then((string) => string.split("\n"))
144+
.then((lines) => lines.find((line) => line.match(/git@.*:.*\/.*.git$/)))
145+
.then((line) => line?.replace(/.*:/, "").replace(".git", "").split("/"));
146+
if (!remote) return;
147+
const [owner, repo] = remote;
148+
149+
const gitHead = Uri.file(`${pathToWorkspace}/.git/HEAD`);
150+
const branch = await workspace.fs
151+
.readFile(gitHead)
152+
.then((buf) => buf.toString())
153+
.then((string) => string.replace("ref: refs/heads/", "").slice(0, -1));
154+
155+
if (!branch) return;
156+
157+
// Don't need this right now, but may be useful in the future if we want to cache coverage
158+
//const gitRefFile = Uri.file(`${pathToWorkspace}/.git/refs/heads/${branch}`);
159+
//const commitHash = await workspace.fs
160+
// .readFile(gitRefFile)
161+
// .then((buf) => buf.toString());
162+
//if (!commitHash) return;
163+
164+
const coverageUrl = `${apiUrl}/api/v2/${provider}/${owner}/repos/${repo}/file_report/${path}`;
165+
166+
// First try getting coverage for this branch
167+
let error = null;
168+
let coverage: Coverage = await axios
169+
.get(`${coverageUrl}?branch=${encodeURIComponent(branch)}`, {
170+
headers: {
171+
accept: "application/json",
172+
authorization: `Bearer ${apiKey}`,
173+
},
174+
})
175+
.then((response) => response.data)
176+
.catch(async (error) => {
177+
if (error?.response?.status >= 500) {
178+
const choice = await window.showErrorMessage(
179+
"Codecov: Unable to connect to server or something went seriously wrong.",
180+
"Reset your API key"
181+
);
182+
if (choice === "Reset your API key")
183+
await commands.executeCommand(command);
184+
} else if (error?.response.status === 401) {
185+
const choice = await window.showErrorMessage(
186+
"Codecov: The provided API key is not authorized to access this repository.",
187+
"Reset your API key"
188+
);
189+
if (choice === "Reset your API key")
190+
await commands.executeCommand(command);
191+
}
192+
error = error;
193+
});
194+
195+
if (error) return;
196+
197+
if (!coverage || !coverage.line_coverage) {
198+
// No coverage for this file/branch. Fall back to default branch coverage.
199+
coverage = await axios
200+
.get(coverageUrl, {
201+
headers: {
202+
accept: "application/json",
203+
authorization: `Bearer ${apiKey}`,
204+
},
205+
})
206+
.then((response) => response.data);
207+
}
208+
209+
if (!coverage || !coverage.line_coverage) return;
210+
211+
const coveredLines: Range[] = [];
212+
const partialLines: Range[] = [];
213+
const missedLines: Range[] = [];
214+
215+
coverage.line_coverage.forEach((line) => {
216+
if (line[1] === 0) {
217+
coveredLines.push(
218+
new Range(new Position(line[0] - 1, 0), new Position(line[0] - 1, 0))
219+
);
220+
} else if (line[1] === 2) {
221+
partialLines.push(
222+
new Range(new Position(line[0] - 1, 0), new Position(line[0] - 1, 0))
223+
);
224+
} else {
225+
missedLines.push(
226+
new Range(new Position(line[0] - 1, 0), new Position(line[0] - 1, 0))
227+
);
228+
}
229+
});
230+
231+
activeEditor.setDecorations(lineCoveredDecoration, coveredLines);
232+
activeEditor.setDecorations(linePartialDecoration, partialLines);
233+
activeEditor.setDecorations(lineMissedDecoration, missedLines);
234+
}
235+
236+
if (activeEditor) {
237+
updateDecorations();
238+
}
239+
240+
window.onDidChangeActiveTextEditor(
241+
(editor) => {
242+
activeEditor = editor;
243+
if (editor) {
244+
updateDecorations();
245+
}
246+
},
247+
null,
248+
context.subscriptions
249+
);
250+
251+
workspace.onDidSaveTextDocument(
252+
(event) => {
253+
if (activeEditor && event === activeEditor.document) {
254+
updateDecorations();
255+
}
256+
},
257+
null,
258+
context.subscriptions
259+
);
260+
}

src/main.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { ExtensionContext } from "vscode";
2+
import { activateCoverage } from "./coverage/coverage";
3+
import { activateYAML } from "./yaml/yamlClientMain";
4+
5+
export function activate(context: ExtensionContext) {
6+
activateCoverage(context);
7+
return activateYAML(context);
8+
}
File renamed without changes.
File renamed without changes.

0 commit comments

Comments
 (0)