Module: Asana

Defined in:
app/services/asana.rb

Overview

Asana import (card #7): paste a task URL, get a card. First use walks through connecting a Personal Access Token, which lives as a 0600 file in .cardinal/ (like the Claude token) — never in the database, never in git.

Defined Under Namespace

Classes: Error

Constant Summary collapse

API =
"https://app.asana.com/api/1.0".freeze

Class Method Summary collapse

Class Method Details

.comment!(task_url, text) ⇒ Object

Post text to the task as a comment (an Asana "story").



58
59
60
61
# File 'app/services/asana.rb', line 58

def self.comment!(task_url, text)
  request("/tasks/#{task_gid(task_url)}/stories", token,
          method: :post, body: { data: { text: text } })
end

.connected?Boolean

Returns:

  • (Boolean)


15
# File 'app/services/asana.rb', line 15

def self.connected? = !!File.size?(token_path)

.disconnect!Object



24
# File 'app/services/asana.rb', line 24

def self.disconnect! = FileUtils.rm_f(token_path)

.import!(board, url) ⇒ Object



38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# File 'app/services/asana.rb', line 38

def self.import!(board, url)
  data = request("/tasks/#{task_gid(url)}?opt_fields=name,notes,permalink_url,tags.name", token)
  permalink = data["permalink_url"].presence || url
  if (existing = board.cards.find_by(asana_url: permalink))
    return existing
  end

  inbox = board.columns.inbox.order(:position).first
  card = board.cards.create!(
    column: inbox,
    title: data["name"].presence || "Asana task",
    asana_url: permalink,
    tags: Array(data["tags"]).filter_map { |t| t["name"] }.first(5),
    description: "#{data["notes"].presence || "(no description on the Asana task)"}\n\n---\n_Imported from Asana: #{permalink}_"
  )
  card.log!("status_change", actor: "user", text: "Imported from Asana: #{permalink}")
  card
end

.request(path, auth, method: :get, body: nil) ⇒ Object



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'app/services/asana.rb', line 63

def self.request(path, auth, method: :get, body: nil)
  uri = URI("#{API}#{path}")
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl = true
  http.open_timeout = 10
  http.read_timeout = 15
  headers = { "Authorization" => "Bearer #{auth}" }
  response =
    if method == :post
      http.post(uri.request_uri, body.to_json, headers.merge("Content-Type" => "application/json"))
    else
      http.get(uri.request_uri, headers)
    end
  unless [200, 201].include?(response.code.to_i)
    raise Error, "Asana said no (HTTP #{response.code}) — check the token, and that it can see this task"
  end
  JSON.parse(response.body)["data"]
rescue JSON::ParserError
  raise Error, "Asana returned something unreadable"
rescue SocketError, Net::OpenTimeout, Net::ReadTimeout => e
  raise Error, "Couldn't reach Asana (#{e.class.name.demodulize}) — check your connection"
end

.save_token!(value) ⇒ Object



18
19
20
21
22
# File 'app/services/asana.rb', line 18

def self.save_token!(value)
  FileUtils.mkdir_p(File.dirname(token_path))
  File.write(token_path, value.strip)
  File.chmod(0o600, token_path)
end

.task_gid(url) ⇒ Object

Task URLs come in several vintages (/0//, /0/.../f, /1//project/

/task/) — the task gid is the last long digit run.



34
35
36
# File 'app/services/asana.rb', line 34

def self.task_gid(url)
  url.to_s.scan(/\d{6,}/).last or raise Error, "That doesn't look like an Asana task URL"
end

.tokenObject



16
# File 'app/services/asana.rb', line 16

def self.token = File.read(token_path).strip

.token_pathObject



11
12
13
# File 'app/services/asana.rb', line 11

def self.token_path
  Pathname(File.expand_path(ENV["CARDINAL_DATA_DIR"].presence || Rails.root.join(".cardinal"))).join("asana-token")
end

.verify!(candidate) ⇒ Object

Cheapest possible "does this token work" — also gives us a name to show.



27
28
29
30
# File 'app/services/asana.rb', line 27

def self.verify!(candidate)
  data = request("/users/me", candidate)
  data["name"].presence || data["email"].presence || "connected"
end