Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Open Issue status bar button #2

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ Once installed, just go to the command menu with:

And type "Open issue". You'll see a command called "Linear: Open issue" appear.

Additionally, if your current Git branch corresponds to a related issue, a status bar button will appear, allowing you to open the issue in Linear.

This extension uses our VS Code Linear API authentication provider that is exposed by the [linear-connect](https://marketplace.visualstudio.com/items?itemName=Linear.linear-connect) extension. Feel free to use that in your own extensions!

---
Expand Down
Binary file added assets/icons.woff
Binary file not shown.
11 changes: 10 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"Other"
],
"activationEvents": [
"onCommand:linear-open-issue.openIssue"
"onStartupFinished"
],
"capabilities": {
"virtualWorkspaces": true,
Expand All @@ -42,6 +42,15 @@
"description": "Open Linear issue in the desktop app"
}
}
},
"icons": {
"linear-dark-logo": {
"description": "Linear Dark Logo",
"default": {
"fontPath": "assets/icons.woff",
"fontCharacter": "\\0041"
}
}
}
},
"extensionDependencies": [
Expand Down
75 changes: 75 additions & 0 deletions src/buttons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import * as vscode from "vscode";
import { getCurrentBranchName, onRepoChange } from "./common/git";
import { tryFetchCurrentIssue } from "./common/utils";

let openIssueButton: vscode.StatusBarItem;
let lastBranchName: string | undefined;

export function createOpenIssueButton(): { dispose(): any } {
const disposeButton = createButton();
const disposeSyncButton = syncButtonOnIssueChange();

return {
dispose: () => {
disposeButton();
disposeSyncButton();
},
};
}

function createButton() {
openIssueButton = vscode.window.createStatusBarItem(
"linear-open-issue.openIssueButton",
vscode.StatusBarAlignment.Left
);
openIssueButton.command = "linear-open-issue.openIssue";
openIssueButton.name = "Open Issue";
openIssueButton.tooltip = "Open in Linear";

return openIssueButton.dispose;
}

// Observes current branch of the Git repo and fetches corresponding Linear
// issue on each change, updating the button with resulting issue data.
function syncButtonOnIssueChange() {
let repoChangeDisposer: () => any;

let disposed = false;

repoChangeDisposer = onRepoChange(async () => {
const branchName = getCurrentBranchName();
// If branch didn't change, no need to call Linear for the issue.
if (branchName == lastBranchName) return;

lastBranchName = branchName;
hideButton();

if (!branchName) return;

const issue = await tryFetchCurrentIssue();

// Return early if the listener was disposed while fetching issue.
if (disposed) return;

if (issue) {
showButton(issue.identifier);
} else {
hideButton();
}
});

return () => {
disposed = true;
repoChangeDisposer();
};
}

function showButton(issueIdentifier: string) {
openIssueButton.text = `$(linear-dark-logo) ${issueIdentifier}`;
openIssueButton.show();
}

function hideButton() {
openIssueButton.hide();
openIssueButton.text = "";
}
29 changes: 29 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as vscode from "vscode";
import { tryFetchCurrentIssue } from "./common/utils";

export function registerOpenIssueCommand() {
return vscode.commands.registerCommand(
"linear-open-issue.openIssue",
async () => {
const issue = await tryFetchCurrentIssue();
if (!issue) return;

// Preference to open the issue in the desktop app or in the browser.
const urlPrefix = vscode.workspace
.getConfiguration()
.get<boolean>("openInDesktopApp")
? "linear://"
: "https://linear.app/";

// Open the URL.
vscode.env.openExternal(
vscode.Uri.parse(
urlPrefix +
issue.team.organization.urlKey +
"/issue/" +
issue.identifier
)
);
}
);
}
96 changes: 96 additions & 0 deletions src/common/git.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import * as vscode from "vscode";
import { GitExtension, Repository } from "../types.d/git";

export function getCurrentBranchName() {
// Use VS Code's built-in Git extension API to get the current branch name.
const git = getGitExtension()?.exports?.getAPI(1);
const branchName = git?.repositories[0]?.state.HEAD?.name;

if (!branchName) {
vscode.window.showErrorMessage(
`The current branch name could not be determined.`
);
}

return branchName;
}

export function onRepoChange(listener: () => any) {
let repoOpenDisposer: (() => any) | undefined;
let repoCloseDisposer: (() => any) | undefined;
let repoChangeDisposer: (() => any) | undefined;

let disposed = false;

const listenRepoChangeAsync = async () => {
let activeRepo: Repository | undefined;

// Get reference to Git extension or break execution, if it's not available
// or it wasn't activated within expected time.
const gitExtension = await initGitExtension();

// Return early if the listener was disposed while initializing Git extension.
if (disposed) return;

if (!gitExtension) {
vscode.window.showErrorMessage(
`Git extension could not be found in the workspace.`
);
return;
}

const onRepoOpen = (repository: Repository) => {
if (activeRepo) return;
activeRepo = repository;
// Call listener on each repository change until disposed.
repoChangeDisposer = repository.state.onDidChange(listener).dispose;
listener();
};

const onRepoClose = (repository: Repository) => {
if (activeRepo != repository) return;
activeRepo = undefined;
repoChangeDisposer?.();
};

const gitApi = gitExtension.exports.getAPI(1);
if (gitApi.repositories.length > 0) {
onRepoOpen(gitApi.repositories[0]);
}
repoOpenDisposer = gitApi.onDidOpenRepository(onRepoOpen).dispose;
repoCloseDisposer = gitApi.onDidCloseRepository(onRepoClose).dispose;
};

// Listen asynchronously in order to return the disposer synchronously.
listenRepoChangeAsync();

return () => {
disposed = true;
repoOpenDisposer?.();
repoCloseDisposer?.();
repoChangeDisposer?.();
};
}

async function initGitExtension() {
const gitExtension = getGitExtension();
if (!gitExtension) return;

// Wait for 10s for the extension activation.
const maxAttempts = 20;
const retryDelayMillis = 500;

let attempts = 0;
while (!gitExtension.isActive && attempts != maxAttempts) {
attempts++;
await new Promise((resolve) => setTimeout(resolve, retryDelayMillis));
}

if (!gitExtension.isActive) return;

return gitExtension;
}

function getGitExtension() {
return vscode.extensions.getExtension<GitExtension>("vscode.git");
}
63 changes: 63 additions & 0 deletions src/common/linear.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { LinearClient } from "@linear/sdk";
import * as vscode from "vscode";

export type Issue = {
identifier: string;
team: {
organization: {
urlKey: string;
};
};
};

export async function fetchIssue(branchName: string) {
const client = await createLinearClient();
if (!client) return;

const request: {
issueVcsBranchSearch: Issue | null;
} | null = await client.request(`query {
issueVcsBranchSearch(branchName: "${branchName}") {
identifier
team {
organization {
urlKey
}
}
}
}`);

const issue = request?.issueVcsBranchSearch;

if (!issue) {
vscode.window.showInformationMessage(
`No Linear issue could be found matching the branch name ${branchName} in the authenticated workspace.`
);
}

return issue;
}

async function createLinearClient() {
const LINEAR_AUTHENTICATION_PROVIDER_ID = "linear";
const LINEAR_AUTHENTICATION_SCOPES = ["read"];

const session = await vscode.authentication.getSession(
LINEAR_AUTHENTICATION_PROVIDER_ID,
LINEAR_AUTHENTICATION_SCOPES,
{ createIfNone: true }
);

if (!session) {
vscode.window.showErrorMessage(
`We weren't able to log you into Linear when trying to open the issue.`
);
return;
}

const linearClient = new LinearClient({
accessToken: session.accessToken,
});

return linearClient.client;
}
17 changes: 17 additions & 0 deletions src/common/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import * as vscode from "vscode";
import { getCurrentBranchName } from "./git";
import { fetchIssue } from "./linear";

export async function tryFetchCurrentIssue() {
// Fetches Linear issue based on the current Git branch name.
const branchName = getCurrentBranchName();
if (!branchName) return;

try {
return await fetchIssue(branchName);
} catch (error) {
vscode.window.showErrorMessage(
`An error occurred while trying to fetch Linear issue information. Error: ${error}`
);
}
}
Loading